mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user