try to deploy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-13 21:27:56 +08:00
parent bfc4b9fc67
commit b1805cfe4d
87 changed files with 798 additions and 718 deletions
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import api from "@/utils/api.util";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import DataTable from "@/components/generic/Table/DataTable";
@@ -14,6 +16,7 @@ import { buildRowActions } from "../../config/advertisements/arc
import { buildSelectionActions } from "../../config/advertisements/archive/selection.config";
import { getTimestamp } from "@/utils/timestamp.util";
import { useDateFormat } from "@/hooks/useDateFormat";
export default function ArchivedAdvertisementsTable() {
const [restoreTarget, setRestoreTarget] = useState(null);
@@ -29,6 +32,7 @@ export default function ArchivedAdvertisementsTable() {
});
const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const {
advertisements, attributes, pagination, setPagination, loading,
@@ -57,6 +61,22 @@ export default function ArchivedAdvertisementsTable() {
onDelete: (row) => setDeleteTarget(row),
});
// "Remove Expired" — the expireAdvertisements cron already auto-archives
// (soft-deletes) any ad past its end_date and stamps status: 'expired' on
// it before doing so, so this only ever targets naturally-expired ads,
// never ones an admin manually archived while still active/scheduled.
const handleRemoveExpired = async () => {
const { data } = await api.get("/admin/advertisements/archived", {
params: { page: 1, limit: 1000, filters: JSON.stringify([{ id: "status", value: "expired" }]) },
});
const ids = (data?.data?.data ?? []).map((a) => a.advertisement_id);
if (!ids.length) {
toast("No expired advertisements to remove.");
return;
}
setDeleteIds(ids);
};
const toolbarActions = buildToolbarActions({
fetchAdvertisements: fetchArchivedAdvertisements,
pagination,
@@ -65,6 +85,7 @@ export default function ArchivedAdvertisementsTable() {
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
onRemoveExpired: handleRemoveExpired,
});
const selectionActions = buildSelectionActions({
@@ -77,8 +98,8 @@ export default function ArchivedAdvertisementsTable() {
});
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes]
() => buildDataColumns(attributes, rowActions, fmtDateTime),
[attributes, fmtDateTime]
);
const handleRestoreSuccess = () => {
@@ -2,7 +2,6 @@
to Client side */
import { Eye, ImageIcon, VideoIcon, ZoomIn } from "lucide-react";
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
import { PhotoProvider, PhotoView } from "react-photo-view";
import { VideoBlock } from "@/components/generic/Blocks/Client/VideoBlock";
import { TextVideoBlock } from "@/components/generic/Blocks/Client/TextVideoBlock";
@@ -159,7 +158,7 @@ export function PreviewBlock({ block }) {
// PhotoProvider wraps ALL blocks so images across the whole lesson share
// one lightbox session — users can swipe between them naturally.
export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
export function PreviewContent({ lesson, blocks, empty = "No content yet.", showHeader = true }) {
return (
<PhotoProvider
speed={() => 300}
@@ -176,8 +175,7 @@ export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
</div>
)}
>
<style>{WYSIWYG_STYLES}</style>
<LessonHeader lesson={lesson} />
{showHeader && <LessonHeader lesson={lesson} />}
{blocks.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-sm text-muted-foreground sm:py-16">
<Eye className="h-6 w-6 opacity-20 sm:h-8 sm:w-8" />
@@ -9,18 +9,28 @@ export const columnPinning = {
left: [],
};
const cellOverrides = {};
const DATE_TIME_FIELDS = ["start_date", "end_date", "createdAt", "updatedAt", "deletedAt"];
/**
* Builds the full column array for the Archived Advertisements table.
*
* @param {Array} attributes Field definitions from the server (drives data columns)
* @param {Array} rowActions Row-level kebab action definitions
* @param {Function} [fmtDateTime] - date+time formatter (from useDateFormat), falls back to a plain locale string
* @returns {Array} TanStack column definitions
*/
export function buildDataColumns(attributes, rowActions) {
export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v ? new Date(v).toLocaleString() : "—") {
const visibleAttributes = attributes.filter((a) => !a.hidden);
// Ad scheduling is time-sensitive — the default date-only cell renderer
// drops the time-of-day, so these fields get an explicit date+time cell.
const cellOverrides = Object.fromEntries(
DATE_TIME_FIELDS.map((field) => [
field,
(info) => <span className="text-xs text-muted-foreground">{fmtDateTime(info.getValue())}</span>,
])
);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
@@ -1,5 +1,5 @@
// config/advertisements/archive/toolbar.config.jsx
import { RefreshCw, Download } from "lucide-react";
import { RefreshCw, Download, Ban } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
@@ -11,8 +11,9 @@ import { exportTableToExcel } from "@/utils/excel.util";
* @param {Function} deps.getFilters
* @param {Function} deps.getSort
* @param {Function} deps.getTableInstance
* @param {Function} [deps.onRemoveExpired] - triggers the "Remove Expired" bulk-purge flow
*/
export function buildToolbarActions({ fetchAdvertisements, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) {
export function buildToolbarActions({ fetchAdvertisements, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance, onRemoveExpired }) {
return [
{
key: "refresh",
@@ -38,5 +39,13 @@ export function buildToolbarActions({ fetchAdvertisements, pagination, exportCon
tableInstance: table ?? getTableInstance(),
}),
},
...(onRemoveExpired ? [{
key: "remove-expired",
type: "button",
icon: <Ban className="h-3.5 w-3.5" />,
label: "Remove Expired",
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: onRemoveExpired,
}] : []),
];
}
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useUsers } from "@/contexts/AdminUserContext";
import { House, RefreshCw, ChevronLeft, ChevronRight, ExternalLink, CalendarIcon } from "lucide-react";
import { House, RefreshCw, ChevronLeft, ChevronRight, ExternalLink } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -9,9 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { DatePickerButton } from "@/components/generic/DatePickerButton";
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util";
@@ -195,57 +194,6 @@ export default function ActivityFeed() {
);
}
// ─── DatePickerButton ─────────────────────────────────────────────────────────
function DatePickerButton({ value, onChange, placeholder, disabled }) {
const [open, setOpen] = useState(false);
const { fmtDate } = useDateFormat();
const label = value ? fmtDate(value) : placeholder;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`h-8 text-sm w-[150px] justify-start font-normal gap-2 ${!value ? "text-muted-foreground" : ""}`}
>
<CalendarIcon className="size-3.5 shrink-0" />
<span className="truncate">{label}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={(d) => { onChange(d ?? null); setOpen(false); }}
disabled={disabled}
initialFocus
/>
<div className="border-t px-3 py-2 flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-7 text-xs"
onClick={() => { onChange(new Date()); setOpen(false); }}
>
Today
</Button>
{value && (
<Button
variant="ghost"
size="sm"
className="flex-1 h-7 text-xs text-muted-foreground"
onClick={() => { onChange(null); setOpen(false); }}
>
Clear
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
}
// ─── Row ──────────────────────────────────────────────────────────────────────
function initials(name, email) {
if (name) return name.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase();
@@ -293,10 +241,10 @@ function ActivityRow({ row, onViewUser }) {
{ts ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
<span className="text-muted-foreground cursor-default">{fmtDateTime(ts)}</span>
</TooltipTrigger>
<TooltipContent side="left">
{fmtDateTime(ts)}
{timeAgo(ts)}
</TooltipContent>
</Tooltip>
) : (
@@ -8,13 +8,20 @@ import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { DatePickerButton } from "@/components/generic/DatePickerButton";
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util";
import { fmtISO } from "@/utils/datetime.util";
import { useDateFormat } from "@/hooks/useDateFormat";
const LIMIT = 20;
function toDateStr(d) {
if (!d) return undefined;
return fmtISO(d);
}
export default function UserActivityPage() {
const { userId } = useParams();
const navigate = useNavigate();
@@ -22,6 +29,8 @@ export default function UserActivityPage() {
const [page, setPage] = useState(1);
const [action, setAction] = useState("all");
const [from, setFrom] = useState(null);
const [to, setTo] = useState(null);
const load = useCallback(
(p = 1) => {
@@ -29,14 +38,16 @@ export default function UserActivityPage() {
page: p,
limit: LIMIT,
action: action === "all" ? undefined : action,
from: toDateStr(from),
to: toDateStr(to),
});
setPage(p);
},
[fetchUserActivity, userId, action]
[fetchUserActivity, userId, action, from, to]
);
useEffect(() => { fetchUser(userId); }, [userId]);
useEffect(() => { load(1); }, [action, userId]);
useEffect(() => { load(1); }, [action, from, to, userId]);
const displayName = user?.personal_info?.name?.full_name ?? user?.email ?? `User #${userId}`;
@@ -76,7 +87,7 @@ export default function UserActivityPage() {
</div>
{/* ─── Filter ──────────────────────────────────────────────── */}
<div className="flex items-center gap-3 bg-card border rounded-lg p-4">
<div className="flex flex-wrap items-end gap-3 bg-card border rounded-lg p-4">
<div className="flex flex-col gap-1 min-w-[180px]">
<span className="text-xs text-muted-foreground">Filter by action</span>
<Select value={action} onValueChange={setAction}>
@@ -91,10 +102,26 @@ export default function UserActivityPage() {
</SelectContent>
</Select>
</div>
{action !== "all" && (
<div className="flex items-end pt-5">
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setAction("all")}>
Clear
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">From</span>
<DatePickerButton value={from} onChange={setFrom} placeholder="Start date" />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">To</span>
<DatePickerButton value={to} onChange={setTo} placeholder="End date" disabled={from ? { before: from } : undefined} />
</div>
{(action !== "all" || from || to) && (
<div className="flex items-end">
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => { setAction("all"); setFrom(null); setTo(null); }}
>
Clear filters
</Button>
</div>
)}
@@ -187,10 +214,10 @@ function ActivityItem({ row }) {
{ts ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
<span className="text-muted-foreground cursor-default">{fmtDateTime(ts)}</span>
</TooltipTrigger>
<TooltipContent side="left">
{fmtDateTime(ts)}
{timeAgo(ts)}
</TooltipContent>
</Tooltip>
) : (
@@ -13,6 +13,7 @@ import {
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveAssetSrc } from "@/utils/media.util";
import { cn } from "@/lib/utils";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -453,6 +454,7 @@ function SummaryRow({ label, value }) {
}
function StepReview({ data, selectedAsset, imageUrl }) {
const { fmtDateTime } = useDateFormat();
const placementMeta = PLACEMENT_MAP[data.placement];
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
const hasLandingPage = !data.redirect_link && (data.landing_page?.title || data.landing_page?.body);
@@ -519,8 +521,8 @@ function StepReview({ data, selectedAsset, imageUrl }) {
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Scheduling & display</p>
<SummaryRow label="Start date" value={data.start_date} />
<SummaryRow label="End date" value={data.end_date} />
<SummaryRow label="Start date" value={data.start_date ? fmtDateTime(data.start_date) : null} />
<SummaryRow label="End date" value={data.end_date ? fmtDateTime(data.end_date) : null} />
<SummaryRow label="Order" value={data.order} />
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
</div>
@@ -693,7 +695,7 @@ export default function AddAdvertisement() {
<Button
type="button"
variant="outline"
onClick={stepIndex === 0 ? () => navigate(-1) : handleBack}
onClick={stepIndex === 0 ? () => navigate("/admin/advertisements") : handleBack}
disabled={loading}
>
<ChevronLeft className="size-4" />
@@ -17,11 +17,11 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_FILTERABLE_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { PLACEMENT_MAP } from "@/data/placement.data";
import { TablePagination } from "@/components/generic/Table/TablePagination";
const PAGE_SIZE = 24;
const DEFAULT_PAGE_SIZE = 10;
export default function AdvertisementList() {
const navigate = useNavigate();
@@ -32,6 +32,7 @@ export default function AdvertisementList() {
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const buildFilters = () => {
const filters = [];
@@ -45,13 +46,14 @@ export default function AdvertisementList() {
// their state update with setPage(1) in the same handler so this only
// ever fires once per change (no separate "reset page" effect racing it).
useEffect(() => {
fetchAdvertisements({ page, limit: PAGE_SIZE, filters: buildFilters() });
fetchAdvertisements({ page, limit: pageSize, filters: buildFilters() });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeFilter, statusFilter, search, page]);
}, [typeFilter, statusFilter, search, page, pageSize]);
const handleTypeFilter = (v) => { setTypeFilter(v); setPage(1); };
const handleStatusFilter = (v) => { setStatusFilter(v); setPage(1); };
const runSearch = () => { setSearch(searchInput); setPage(1); };
const handlePageSizeChange = (size) => { setPageSize(size); setPage(1); };
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -61,7 +63,6 @@ export default function AdvertisementList() {
const total = pagination?.totalRecords ?? advertisements.length;
const activeCount = advertisements.filter((a) => a.status === "active").length;
const scheduledCount = advertisements.filter((a) => a.status === "scheduled").length;
const expiredCount = advertisements.filter((a) => a.status === "expired").length;
async function handleArchive(advertisementId) {
await archiveAdvertisement(advertisementId);
@@ -95,11 +96,10 @@ export default function AdvertisementList() {
</div>
{/* ── Stat cards ─────────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<StatCard label="Total ads" value={total} />
<StatCard label="Active" value={activeCount} tone="success" />
<StatCard label="Scheduled" value={scheduledCount} tone="info" />
<StatCard label="Expired" value={expiredCount} tone="muted" />
</div>
{/* ── Filters ────────────────────────────────────────────────── */}
@@ -122,7 +122,7 @@ export default function AdvertisementList() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{ADVERTISEMENT_STATUSES.map((s) => (
{ADVERTISEMENT_FILTERABLE_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
@@ -170,6 +170,7 @@ export default function AdvertisementList() {
<TablePagination
pagination={pagination}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
rowCount={advertisements.length}
recordLabel="advertisement"
/>
@@ -203,7 +204,7 @@ function StatCard({ label, value, tone = "default" }) {
// ─── Advertisement card ─────────────────────────────────────────────────────
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
const { fmtDate } = useDateFormat();
const { fmtDateTime } = useDateFormat();
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
const placementMeta = PLACEMENT_MAP[ad.placement] ?? null;
@@ -212,7 +213,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
const previewSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const isDimmed = ad.status === "expired" || ad.status === "archived";
const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDate);
const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDateTime);
return (
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
@@ -213,7 +213,7 @@ export default function EditAdvertisement() {
}, [advertisementId]);
const onSubmit = async (values) => {
if (!isDirty) { bypassOnce(); return navigate(-1); }
if (!isDirty) { bypassOnce(); return navigate("/admin/advertisements"); }
const payload = {
...values,
@@ -506,7 +506,7 @@ export default function EditAdvertisement() {
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate("/admin/advertisements")} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
+12 -3
View File
@@ -34,6 +34,11 @@ function resolveFileType(mimeType = "") {
return "document";
}
// Matches asset_upload.middleware.js on the backend — checked here too so an
// oversized file is rejected instantly instead of only after a full upload
// attempt round-trips to the server.
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
// ─── Schema ───────────────────────────────────────────────────────────────────
const schema = z.object({
@@ -156,6 +161,10 @@ export default function AddAsset() {
const fileType = file ? resolveFileType(file.type) : null;
const setFile = (f) => {
if (f.size > MAX_FILE_SIZE) {
setError("_file", { message: `File exceeds the ${MAX_FILE_SIZE / (1024 * 1024)} MB size limit.` });
return;
}
fileRef.current = f;
setValue("_file", f);
if (!watch("display_name")) setValue("display_name", f.name);
@@ -198,7 +207,7 @@ export default function AddAsset() {
});
setProgress(null);
if (result) { bypassOnce(); navigate(-1); }
if (result) { bypassOnce(); navigate("/admin/assets"); }
};
const progressLabel = {
@@ -213,7 +222,7 @@ export default function AddAsset() {
{/* ── Header ── */}
<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/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -380,7 +389,7 @@ export default function AddAsset() {
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
onClick={() => navigate("/admin/assets")}
disabled={loading}
>
Cancel
@@ -152,7 +152,7 @@ export default function AddAssetsBulk() {
{/* ── Header ── */}
<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/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -213,7 +213,7 @@ export default function AddAssetsBulk() {
{/* ── Actions ── */}
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
<Button type="button" variant="outline" onClick={() => navigate("/admin/assets")}>
Done
</Button>
</div>
+3 -3
View File
@@ -200,7 +200,7 @@ export default function EditAsset() {
if (!result) return;
bypassOnce();
navigate(-1);
navigate("/admin/assets");
};
if (initializing) {
@@ -224,7 +224,7 @@ export default function EditAsset() {
{/* ── Header ── */}
<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/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -334,7 +334,7 @@ export default function EditAsset() {
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
onClick={() => navigate("/admin/assets")}
disabled={loading}
>
Cancel
@@ -44,7 +44,7 @@ export default function ViewAudioAsset() {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
}
@@ -64,7 +64,7 @@ export default function ViewAudioAsset() {
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -113,8 +113,9 @@ export default function ViewAudioAsset() {
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
@@ -42,7 +42,7 @@ export default function ViewDocumentAsset() {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
}
@@ -55,7 +55,7 @@ export default function ViewDocumentAsset() {
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -116,8 +116,9 @@ export default function ViewDocumentAsset() {
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
@@ -40,7 +40,7 @@ export default function ViewImageAsset() {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
}
@@ -52,7 +52,7 @@ export default function ViewImageAsset() {
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -108,8 +108,9 @@ export default function ViewImageAsset() {
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
@@ -49,7 +49,7 @@ export default function ViewVideoAsset() {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
}
@@ -61,7 +61,7 @@ export default function ViewVideoAsset() {
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -143,8 +143,9 @@ export default function ViewVideoAsset() {
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
@@ -65,7 +65,7 @@ export default function AddCategory() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses/categories")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -100,7 +100,7 @@ export default function AddCategory() {
</SectionCard>
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => navigate("/admin/courses/categories")} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Category
@@ -55,7 +55,7 @@ export default function EditCategory() {
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (values) => {
if (!isDirty) { bypassOnce(); return navigate(-1); }
if (!isDirty) { bypassOnce(); return navigate("/admin/courses/categories"); }
const result = await updateCategory(id, values);
if (!result) return;
bypassOnce();
@@ -76,7 +76,7 @@ export default function EditCategory() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses/categories")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -111,7 +111,7 @@ export default function EditCategory() {
</SectionCard>
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => navigate("/admin/courses/categories")} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading || !isDirty}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
@@ -272,7 +272,7 @@ export default function AddCourse() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -653,7 +653,7 @@ export default function AddCourse() {
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/courses")}
>
<ArrowLeft className="h-4 w-4 mr-2" />
{currentStep === 0 ? "Cancel" : "Back"}
@@ -463,7 +463,7 @@ export default function CourseAssessment() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -478,7 +478,7 @@ export default function EditCourse() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -1121,7 +1121,7 @@ export default function EditCourse() {
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(`/admin/courses/${courseId}/view`)}
>
<ArrowLeft className="h-4 w-4 mr-2" />
{currentStep === 0 ? "Cancel" : "Back"}
@@ -382,7 +382,7 @@ export default function ViewAssessment() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -346,7 +346,7 @@ export default function ViewCourse() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -67,7 +67,7 @@ export default function AddLesson() {
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -146,7 +146,7 @@ export default function AddLesson() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
@@ -62,7 +62,7 @@ export default function EditLesson() {
}, [courseId, unitId, lessonId]);
const onSubmit = async (data) => {
if (!isDirty) { bypassOnce(); return navigate(-1); }
if (!isDirty) { bypassOnce(); return navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`); }
const result = await updateLesson(courseId, unitId, lessonId, {
...data,
objectives: data.objectives?.map((o, i) => ({
@@ -74,7 +74,7 @@ export default function EditLesson() {
});
if (!result) return;
bypassOnce();
navigate(-1);
navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`);
};
return (
@@ -84,7 +84,7 @@ export default function EditLesson() {
<div className="w-full max-w-2xl">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -163,7 +163,7 @@ export default function EditLesson() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading || !isDirty}>
@@ -28,6 +28,12 @@ export default function LessonPageBuilder() {
const { fetchLesson, saveLessonPage, course, unit, lesson, lessonPage, loading } = useCourses();
const { user } = useAuth();
// Junction revamp — this builder runs course-scoped AND from the standalone
// Lesson Library (/admin/lessons/:lessonId/page, no :courseId/:unitId params).
const pageViewPath = unitId
? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page/view`
: `/admin/lessons/${lessonId}/page/view`;
const [blocks, setBlocks] = useState([]);
const [initializing, setInitializing] = useState(true);
const [previewVisible, setPreviewVisible] = useState(true);
@@ -105,7 +111,7 @@ export default function LessonPageBuilder() {
updatedBy: user?.user_id,
});
if (!result) return;
navigate(-1);
navigate(pageViewPath);
};
const editorStyle = previewVisible
@@ -124,7 +130,7 @@ export default function LessonPageBuilder() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(pageViewPath)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -152,7 +158,7 @@ export default function LessonPageBuilder() {
<Eye className="h-4 w-4" />
Preview
</Button>
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(pageViewPath)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading}>
@@ -51,7 +51,7 @@ export default function ViewLesson() {
{/* Header */}
<div className="flex items-center justify-between">
<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/${unitId}/lessons`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -16,6 +16,9 @@ export default function ViewLessonPage() {
const builderPath = unitId
? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`
: `/admin/lessons/${lessonId}/page`;
const viewLessonPath = unitId
? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`
: `/admin/lessons/${lessonId}/view`;
const { fetchLesson, lesson, lessonPage } = useCourses();
const [initializing, setInitializing] = useState(true);
@@ -39,7 +42,7 @@ export default function ViewLessonPage() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(viewLessonPath)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -58,7 +58,7 @@ export default function AddUnit() {
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<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>
@@ -89,7 +89,7 @@ export default function AddUnit() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
@@ -56,7 +56,7 @@ export default function EditUnit() {
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (data) => {
if (!isDirty) { bypassOnce(); return navigate(-1); }
if (!isDirty) { bypassOnce(); return navigate(`/admin/courses/${courseId}/units/${unitId}/view`); }
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
if (!result) return;
bypassOnce();
@@ -70,7 +70,7 @@ export default function EditUnit() {
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -101,7 +101,7 @@ export default function EditUnit() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/view`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading || !isDirty}>
@@ -419,7 +419,7 @@ export default function ModifyQuiz() {
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id);
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
navigate(-1);
navigate(`${scopeBase}/view`);
};
// ── Render ─────────────────────────────────────────────────────────────────
@@ -435,7 +435,7 @@ export default function ModifyQuiz() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`${scopeBase}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -281,7 +281,7 @@ export default function ViewUnitQuiz() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`${scopeBase}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -215,7 +215,7 @@ export default function AddLibraryLesson() {
};
const handleBack = () => {
if (step === 0) navigate(-1);
if (step === 0) navigate(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons");
else setStep((s) => s - 1);
};
@@ -250,7 +250,7 @@ export default function AddLibraryLesson() {
<div className="w-full max-w-3xl mx-auto space-y-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(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -62,7 +62,7 @@ export default function EditLibraryLesson() {
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/lessons/${lessonId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -90,7 +90,7 @@ export default function EditLibraryLesson() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/view`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
@@ -407,7 +407,7 @@ export default function AddLibraryUnit() {
};
const handleBack = () => {
if (step === 0) navigate(-1);
if (step === 0) navigate("/admin/units");
else setStep((s) => s - 1);
};
@@ -446,7 +446,7 @@ export default function AddLibraryUnit() {
<div className="w-full max-w-3xl mx-auto space-y-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/units")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -76,7 +76,7 @@ export default function EditLibraryUnit() {
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/units/${unitId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -127,7 +127,7 @@ export default function EditLibraryUnit() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/units/${unitId}/view`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
@@ -25,6 +25,13 @@ import { DateTimePicker } from "@/components/ui/date-time-picker";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
// Internal paths ("/course/123") pass through untouched — everything else
// gets a scheme so the saved URL always matches what goToLink() will open,
// instead of relying on client-side normalization to paper over a bare
// "example.com" the admin typed.
const normalizeLinkUrl = (raw) => (!raw ? null : raw.startsWith("/") ? raw : normalizeExternalUrl(raw));
// ─── Schema ─────────────────────────────────────────────────────────────────
@@ -233,7 +240,7 @@ export default function AddNotificationBroadcast() {
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
link_url: (values.show_in_sticky && link_mode === "link") ? values.link_url.trim() : null,
link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
start_date: values.start_date || null,
@@ -554,7 +561,7 @@ export default function AddNotificationBroadcast() {
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/announcements")}
disabled={loading}
>
<ArrowLeft className="h-4 w-4 mr-2" />
@@ -25,6 +25,13 @@ import { DateTimePicker } from "@/components/ui/date-time-picker";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
// Internal paths ("/course/123") pass through untouched — everything else
// gets a scheme so the saved URL always matches what goToLink() will open,
// instead of relying on client-side normalization to paper over a bare
// "example.com" the admin typed.
const normalizeLinkUrl = (raw) => (!raw ? null : raw.startsWith("/") ? raw : normalizeExternalUrl(raw));
// ─── Schema ─────────────────────────────────────────────────────────────────
@@ -266,7 +273,7 @@ export default function EditNotificationBroadcast() {
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
link_url: (values.show_in_sticky && link_mode === "link") ? values.link_url.trim() : null,
link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
start_date: values.start_date || null,
@@ -590,7 +597,7 @@ export default function EditNotificationBroadcast() {
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/announcements")}
disabled={loading}
>
<ArrowLeft className="h-4 w-4 mr-2" />
@@ -115,7 +115,7 @@ export default function ViewTaskCompletion() {
<div className="flex items-center gap-3 my-6 w-full">
<Button
variant="ghost" size="icon"
onClick={() => navigate(-1)}
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/completions`)}
className="shrink-0"
>
<ArrowLeft className="size-4" />
+2 -2
View File
@@ -233,7 +233,7 @@ export default function AddPlan() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/tiers/plans")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -403,7 +403,7 @@ export default function AddPlan() {
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/tiers/plans")}
disabled={loading}
>
{currentStep === 0 ? "Cancel" : "Back"}
+2 -2
View File
@@ -198,7 +198,7 @@ export default function EditPlan() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/tiers/plans")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -348,7 +348,7 @@ export default function EditPlan() {
)}
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading || impactLoading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => navigate("/admin/tiers/plans")} disabled={loading || impactLoading}>Cancel</Button>
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0}>
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
@@ -225,7 +225,7 @@ function EditTierCategoryInner({ isAdd }) {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/tiers/categories")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -374,7 +374,7 @@ function EditTierCategoryInner({ isAdd }) {
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => navigate("/admin/tiers/categories")} disabled={loading}>Cancel</Button>
<Button type="button" onClick={handleSave} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
{isAdd ? "Create Category" : "Save Changes"}
@@ -158,7 +158,7 @@ export default function PaymentPolicy() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/tiers/plans/${planId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -426,7 +426,7 @@ export default function PaymentPolicy() {
{/* ── Save ─────────────────────────────────────────────────── */}
<div className="flex justify-end gap-3 pt-1">
<Button variant="outline" onClick={() => navigate(-1)} disabled={saving}>
<Button variant="outline" onClick={() => navigate(`/admin/tiers/plans/${planId}/view`)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
@@ -126,7 +126,7 @@ export default function UserTierList() {
<div className="flex items-start justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/users/view/${userId}`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -144,7 +144,7 @@ export default function ViewPayment() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/tiers/payments")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
+1 -1
View File
@@ -772,7 +772,7 @@ export default function ViewPlan() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/tiers/plans")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -459,7 +459,7 @@ export default function AddStaffUserPage() {
<Button
type="button"
variant="outline"
onClick={step === 0 ? () => navigate(-1) : () => setStep(0)}
onClick={step === 0 ? () => navigate("/admin/users") : () => setStep(0)}
>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
@@ -18,10 +18,13 @@ function LessonSkeleton() {
/**
* Props:
* lesson — { id, title, blocks[] }
* loading — true while fetch is in-flight
* lesson — { id, title, blocks[] }
* loading — true while fetch is in-flight
* showHeader — render the title/description/objectives block. Set false
* when the caller already renders its own lesson header
* above (e.g. LessonDetails), to avoid showing it twice.
*/
const LessonBlock = ({ lesson, loading = false }) => {
const LessonBlock = ({ lesson, loading = false, showHeader = true }) => {
if (!lesson && !loading) {
return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
@@ -41,6 +44,7 @@ const LessonBlock = ({ lesson, loading = false }) => {
lesson={lesson}
blocks={lesson.blocks ?? []}
empty="No content blocks yet."
showHeader={showHeader}
/>
</div>
</PreviewChrome>
@@ -46,7 +46,7 @@ export default function AdvertisementLandingPage() {
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 flex flex-col items-center text-center gap-3 py-16">
<Megaphone className="size-8 text-muted-foreground" />
<p className="font-medium">This advertisement is no longer available.</p>
<Button variant="outline" onClick={() => navigate(-1)}>
<Button variant="outline" onClick={() => navigate("/dashboard")}>
<ArrowLeft className="size-4" /> Go back
</Button>
</div>
@@ -61,7 +61,7 @@ export default function AdvertisementLandingPage() {
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 space-y-6 pb-16">
<PageMeta title={page.title ? `${page.title} - STARR` : undefined} description={page.description} />
<Button variant="ghost" size="sm" className="w-fit -ml-2" onClick={() => navigate(-1)}>
<Button variant="ghost" size="sm" className="w-fit -ml-2" onClick={() => navigate("/dashboard")}>
<ArrowLeft className="size-4" /> Back
</Button>
+1 -1
View File
@@ -108,7 +108,7 @@ export default function CourseCheckout() {
This course doesn't have an individual purchase option.
</p>
</div>
<Button onClick={() => navigate(-1)}>
<Button onClick={() => navigate(`/course/${courseId}`)}>
<ArrowLeft className="size-4" /> Go Back
</Button>
</CardContent>
+71 -79
View File
@@ -1,16 +1,17 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import {
House, SendHorizonal, CheckCheck, Check, Hourglass,
House, SendHorizonal, CheckCheck, Check, Hourglass, Clock, Layers, BookOpen, Video,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useEffect } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
import LessonBlock from "../components/LessonBlock.jsx";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -22,54 +23,6 @@ function formatDuration(seconds = 0) {
return `${m}min`;
}
// ─── Sibling lessons sidebar (unit context) ────────────────────────────────────
const UnitLessonsSidebar = ({ unitDetail, currentLessonUuid, onSelect }) => {
if (!unitDetail) return null;
const lessons = unitDetail.lessons ?? [];
return (
<aside className="lg:w-80 shrink-0 bg-muted xs:px-4 xs:py-8 lg:px-4 lg:py-8 flex flex-col gap-3">
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">{unitDetail.title}</span>
<div className="flex flex-col gap-1">
{lessons.map((l) => {
const isCurrent = l.uuid === currentLessonUuid;
const completed = l.status === "completed";
return (
<div
key={l.lesson_id}
onClick={() => onSelect(l)}
className={cn(
"flex items-center justify-between gap-2 py-2.5 px-3 rounded-lg cursor-pointer text-sm transition-colors",
isCurrent
? "bg-background border shadow-sm font-medium text-card-foreground"
: "hover:bg-background/60"
)}
>
<span className={cn(
"truncate",
!isCurrent && !completed && "text-muted-foreground"
)}>
{l.title}
</span>
{completed ? (
<Check className="size-4 text-emerald-500 shrink-0" />
) : formatDuration(l.duration_seconds) && (
<span className={cn(
"text-xs shrink-0",
isCurrent ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground"
)}>
{formatDuration(l.duration_seconds)}
</span>
)}
</div>
);
})}
</div>
</aside>
);
};
// ─── Lesson Details ─────────────────────────────────────────────────────────
const LessonDetails = () => {
@@ -78,13 +31,22 @@ const LessonDetails = () => {
const {
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
getUnitDetail, unitDetail, resetUnitDetail,
upsertLessonProgress,
} = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
const hasCompleted = lesson?.status === "completed";
const unit = lesson?.unit ?? null;
const hasUnit = !!unit?.uuid;
const hasCourse = !!unit?.course;
// duration_seconds is derived from authored lesson content (see duration.util.js) —
// zero means no lesson content has been built yet, so it isn't ready to view.
const contentNotReady = !lesson?.duration_seconds
|| (hasUnit && !unit?.duration_seconds)
|| (hasCourse && !unit.course.duration_seconds);
const blockTypes = new Set((lesson?.blocks ?? []).map((b) => b.type));
const isVideoLesson = blockTypes.has("video") || blockTypes.has("text-video");
useEffect(() => {
getTierCategories();
@@ -93,20 +55,20 @@ const LessonDetails = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uuid]);
useEffect(() => {
if (!hasUnit) return;
getUnitDetail(unit.uuid);
return () => resetUnitDetail();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unit?.uuid]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Lessons", to: `/lessons` },
...(hasCourse ? [{ label: unit.course.title, to: `/course/${unit.course.course_id}` }] : []),
...(hasUnit ? [{ label: unit.title, to: `/units/${unit.uuid}` }] : []),
{ label: lesson?.title ?? "Lesson" },
];
const badge = hasCourse
? { label: "Unit lesson · Part of a course", icon: BookOpen }
: hasUnit
? { label: "Unit lesson", icon: Layers }
: { label: "Standalone lesson", icon: Clock };
// ── Deep-link to a lesson under a locked unit — inline blocked panel ────
if (unitBlocked) {
return <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
@@ -126,24 +88,41 @@ const LessonDetails = () => {
if (!hasUnit) return;
navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } });
};
const handleSelectSibling = (sibling) => {
if (sibling.uuid === uuid) return;
navigate(`/lessons/${sibling.uuid}`);
const handleMarkComplete = () => {
if (hasCompleted) return;
upsertLessonProgress(lesson.uuid, "completed", unit?.uuid);
};
return (
<div className="flex-1 flex flex-col">
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
<div
className="flex-1 flex flex-col lg:flex-row items-stretch"
className="flex-1 flex flex-col"
style={{ paddingTop: "var(--navbar-h)" }}
>
<div className="flex flex-col gap-6 flex-1 min-w-0 xs:px-4 xs:py-8 lg:px-16 lg:py-10">
<AppBreadcrumb items={items} />
<div className="flex flex-col gap-3 max-w-2xl">
<Badge variant="secondary" className="w-fit uppercase tracking-wide gap-1.5 px-2.5 py-1">
<badge.icon className="size-3" />
{badge.label}
</Badge>
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
<p className="text-muted-foreground">{lesson.description ?? ""}</p>
{!hasCourse && (
<div className="flex items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
{formatDuration(lesson.duration_seconds) && (
<>
<span className="flex items-center gap-1"><Clock /> {formatDuration(lesson.duration_seconds)}</span>
<span>·</span>
</>
)}
<span className="flex items-center gap-1">
<Video /> {isVideoLesson ? "Video lesson" : "Reading lesson"}
</span>
</div>
)}
</div>
{lesson.objectives?.length > 0 && (
@@ -160,30 +139,43 @@ const LessonDetails = () => {
</div>
)}
<div className="w-fit">
{!hasUnit ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
<Hourglass className="size-4 shrink-0" />
This lesson isn't part of a unit yet. Check back later.
</div>
) : (
{contentNotReady ? (
<div className="w-fit flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
<Hourglass className="size-4 shrink-0" />
This lesson is currently being prepared. Please check back later.
</div>
) : hasCourse ? (
<div className="w-fit">
<Button className="w-fit bg-blue-500" onClick={handleStart}>
{hasCompleted
? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Lesson</>
}
</Button>
)}
</div>
</div>
) : (
<>
<div className="w-full">
<LessonBlock lesson={lesson} loading={lessonLoading} showHeader={false} />
</div>
<div className="flex items-center justify-between gap-4 max-w-2xl">
<p className="text-sm text-muted-foreground">
{hasUnit
? `This lesson is part of the "${unit.title}" unit — progress is tracked on its own.`
: "This lesson isn't part of a course — progress is tracked on its own."
}
</p>
<Button
className="shrink-0 bg-blue-500"
disabled={hasCompleted}
onClick={handleMarkComplete}
>
{hasCompleted ? <><CheckCheck /> Completed</> : "Mark as Complete"}
</Button>
</div>
</>
)}
</div>
{hasUnit && (
<UnitLessonsSidebar
unitDetail={unitDetail}
currentLessonUuid={uuid}
onSelect={handleSelectSibling}
/>
)}
</div>
</div>
);
+1 -1
View File
@@ -26,7 +26,7 @@ export default function MyAchievements() {
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
<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("/dashboard")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
+1 -1
View File
@@ -120,7 +120,7 @@ export default function MyCertificates() {
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
<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("/dashboard")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
+4 -4
View File
@@ -25,7 +25,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { cn } from "@/lib/utils";
import { NotificationIcon, timeAgo } from "@/components/generic/notificationDisplay";
import { NotificationIcon, getTypeAccent, timeAgo } from "@/components/generic/notificationDisplay";
import NotificationDetailDialog from "@/components/generic/NotificationDetailDialog";
import { toast } from "sonner";
@@ -154,7 +154,7 @@ export default function Notifications() {
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/dashboard")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -214,8 +214,8 @@ export default function Notifications() {
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<NotificationIcon type={n.type} className="h-4.5 w-4.5 text-muted-foreground" />
<div className={cn("mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full", getTypeAccent(n.type))}>
<NotificationIcon type={n.type} className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
+9 -1
View File
@@ -1,10 +1,18 @@
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
const DASHBOARD_BY_ROLE = {
admin: '/admin',
user: '/dashboard',
staff: '/staff',
}
export default function NotFound() {
const navigate = useNavigate()
const { user } = useAuth()
const goHome = () => {
navigate(-1)
navigate(DASHBOARD_BY_ROLE[user?.acc_type] ?? '/')
}
return (
+9 -1
View File
@@ -1,10 +1,18 @@
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
const DASHBOARD_BY_ROLE = {
admin: '/admin',
user: '/dashboard',
staff: '/staff',
}
export default function Unauthorized() {
const navigate = useNavigate()
const { user } = useAuth()
const goHome = () => {
navigate(-1)
navigate(DASHBOARD_BY_ROLE[user?.acc_type] ?? '/')
}
return (