add: more commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:21:27 +08:00
parent 17326b2c2e
commit 7e964f2432
112 changed files with 9160 additions and 3461 deletions
+40 -45
View File
@@ -9,7 +9,6 @@ import { Spinner } from "@/components/ui/spinner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAssets } from "@/contexts/AdminAssetsContext";
import api from "@/utils/api.util";
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
const DEBOUNCE_MS = 400;
@@ -73,23 +72,34 @@ function EmptyState({ fileType }) {
// ─── Main Sheet ───────────────────────────────────────────────────────────────
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
if (!open) return null;
// NOTE: previously this returned null before any hooks ran when `open` was
// false. Since the parent renders this component unconditionally (only the
// `open` prop toggles), that meant React remounted every hook from scratch
// on each open — wiping local state and forcing a full refetch every time,
// plus skipping the <Sheet> close transition. Visibility is controlled by
// <Sheet open={open}> below instead, so state (and the caches in
// AdminAssetsContext) survive across open/close toggles.
const { fetchAssets, assets, pagination, loading } = useAssets();
const { fetchAssets, assets, pagination, loading, mediaTokens, getMediaTokens } = useAssets();
const [search, setSearch] = useState("");
const [activeExts, setActiveExts] = useState(new Set());
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
const [filterOpen, setFilterOpen] = useState(false);
// { [asset_id]: streamUrl } — resolved once per asset list via batch token request
const [streamUrls, setStreamUrls] = useState({});
const debounceRef = useRef(null);
const isFirstSearchRun = useRef(true);
const LIMIT = 12;
const extOptions = EXT_OPTIONS[fileType] ?? [];
const resolveStreamSrc = useCallback((assetId) => {
const entry = mediaTokens[String(assetId)];
if (!entry) return null;
return entry.thumbnail_url ?? `${STREAM_BASE}/${entry.token}`;
}, [mediaTokens]);
// ── Build and fire fetch ──────────────────────────────────────────────────
const doFetch = useCallback((searchVal, extSet, pg) => {
const filters = [
@@ -100,8 +110,19 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
fetchAssets({ page: pg, limit: LIMIT, filters });
}, [fileType, fetchAssets]);
// ── Auto-search: debounce on search input change ──────────────────────────
// ── Immediate fetch: on open, or when filters/page change while open ──────
// (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening
// with the same query within the cache window costs no network round-trip.)
useEffect(() => {
if (!open) return;
doFetch(search, activeExts, page);
}, [open, activeExts, page]);
// ── Debounced fetch: only when the user edits the search box ──────────────
// Deliberately does NOT depend on `open` — otherwise this and the effect
// above both fire on every sheet open, doubling the request.
useEffect(() => {
if (isFirstSearchRun.current) { isFirstSearchRun.current = false; return; }
if (!open) return;
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
@@ -109,51 +130,26 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
doFetch(search, activeExts, 1);
}, DEBOUNCE_MS);
return () => clearTimeout(debounceRef.current);
}, [search, open]);
// ── Immediate fetch on ext filter or page change ──────────────────────────
useEffect(() => {
if (!open) return;
doFetch(search, activeExts, page);
}, [activeExts, page, open]);
}, [search]);
// ── Batch token fetch after assets load ───────────────────────────────────
// One request for all S3 assets on the current page instead of N per-card requests.
// This eliminates the thundering-herd / auth-refresh race that caused some cards to
// silently show "No preview" after a page reload (multiple 401s queuing simultaneously
// while the interceptor refreshes, some dropping if cancelled mid-flight).
// One request for all S3 assets on the current page instead of N per-card
// requests. getMediaTokens (AdminAssetsContext) already skips any asset_id
// whose cached token is still valid, so reopening the sheet within the
// token's ~30min TTL issues no request at all for previously-seen assets.
useEffect(() => {
if (!assets.length) return;
if (!open || !assets.length) return;
// Only request tokens for S3 assets we don't already have a URL for —
// this prevents duplicate POST /tokens when the assets list triggers
// this effect more than once per sheet open (e.g., two fetch effects
// both reacting to open mounting, producing two assets updates).
const s3Ids = assets
.filter((a) => a.storage_provider === "s3" && !streamUrls[String(a.asset_id)])
.filter((a) => a.storage_provider === "s3")
.map((a) => a.asset_id);
if (!s3Ids.length) return;
let cancelled = false;
api.post("/admin/media/tokens", { asset_ids: s3Ids })
.then(({ data }) => {
if (cancelled) return;
const tokens = data.data?.tokens ?? {};
const thumbnails = data.data?.thumbnails ?? {};
const urls = {};
for (const [id, token] of Object.entries(tokens)) {
// Prefer presigned thumbnail URL (faster, direct); fall back to stream proxy
urls[id] = thumbnails[id] ?? `${STREAM_BASE}/${token}`;
}
setStreamUrls((prev) => ({ ...prev, ...urls }));
})
.catch((err) => {
console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
});
return () => { cancelled = true; };
}, [assets]);
getMediaTokens(s3Ids).catch((err) => {
console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
});
}, [assets, open, getMediaTokens]);
// ── Reset on close ────────────────────────────────────────────────────────
useEffect(() => {
@@ -163,7 +159,6 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
setPage(1);
setSelected(null);
setFilterOpen(false);
setStreamUrls({});
}
}, [open]);
@@ -187,7 +182,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
// (e.g. badge image picker) can use the authenticated URL directly
// rather than falling back to asset.file_url which is a private CDN
// key that the browser cannot load without S3 credentials.
const resolvedUrl = streamUrls[String(asset.asset_id)]
const resolvedUrl = resolveStreamSrc(asset.asset_id)
?? asset.thumbnail_url
?? asset.file_url
?? null;
@@ -304,7 +299,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
<AssetCard
key={asset.asset_id}
asset={asset}
streamSrc={streamUrls[String(asset.asset_id)] ?? null}
streamSrc={resolveStreamSrc(asset.asset_id)}
selected={selected === asset.asset_id}
onSelect={handleSelect}
/>
@@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { AssetPickerSheet } from "../../AssetPickerSheet";
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
@@ -173,9 +174,7 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
{!readOnly && <Label>Audio</Label>}
{isS3 && tokenLoading ? (
<div className="w-full max-w-lg rounded-xl border border-border bg-card flex items-center justify-center h-28">
<div className="w-5 h-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
</div>
<MediaFallback className="w-full max-w-lg h-28 rounded-xl border border-border" />
) : src ? (
<div className="w-full max-w-lg rounded-xl overflow-hidden border border-border bg-card text-card-foreground shadow-sm">
<audio
@@ -2,12 +2,16 @@
import { Megaphone } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { resolveAssetSrc } from "@/utils/media.util";
// Height per size — controls the strip's visual weight, not its width (always full-width).
// sm/md are fixed; lg/xl use clamp() so they scale with viewport width between
// a min and max instead of jumping at breakpoints.
const SIZE_HEIGHT = {
sm: "h-20",
md: "h-32",
lg: "h-48",
sm: "h-[80px]",
md: "h-[140px]",
lg: "h-[clamp(220px,26vw,320px)]",
xl: "h-[clamp(320px,34vw,460px)]",
};
// ── Banner ───────────────────────────────────────────────────────────────────
@@ -19,13 +23,13 @@ const SIZE_HEIGHT = {
*
* Props:
* ad — advertisement object { headline, ctas, image, image_url, advertisement_id }
* size — "sm" | "md" | "lg" (default "md")
* size — "sm" | "md" | "lg" | "xl" (default "md")
* onCtaClick — (ad, cta) => void, called on click. cta may be undefined if the ad has none.
*/
export function Banner({ ad, size, onCtaClick }) {
if (!ad) return null;
const imageSrc = ad.image?.file_url || ad.image_url || null;
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const resolvedSize = ad.size || size || "md";
const heightClass = SIZE_HEIGHT[resolvedSize] ?? SIZE_HEIGHT.md;
@@ -39,13 +43,13 @@ export function Banner({ ad, size, onCtaClick }) {
className={`relative w-full rounded-lg bg-muted overflow-hidden flex items-center justify-center text-left ${heightClass}`}
>
{imageSrc ? (
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover pointer-events-none select-none" />
) : (
<Megaphone className="size-6 text-muted-foreground" />
<Megaphone className="size-6 text-muted-foreground pointer-events-none select-none" />
)}
{ad.headline && (
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 flex items-end p-4">
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 flex items-end p-4 pointer-events-none select-none">
<p className="text-white font-medium text-sm sm:text-base">{ad.headline}</p>
</div>
)}
@@ -4,6 +4,7 @@ import { Megaphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { resolveAssetSrc } from "@/utils/media.util";
// ── Hero ─────────────────────────────────────────────────────────────────────
/**
@@ -18,24 +19,24 @@ import { Skeleton } from "@/components/ui/skeleton";
export function Hero({ ad, onCtaClick }) {
if (!ad) return null;
const imageSrc = ad.image?.file_url || ad.image_url || null;
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
return (
<div className="flex xs:flex-col lg:flex-row items-center gap-6">
<div className="flex xs:flex-col lg:flex-row items-center justify-between gap-6">
<div className="flex flex-col gap-3">
{ad.badge_label && (
<Badge variant="outline">
<Badge variant="outline" className="pointer-events-none select-none">
<Megaphone /> {ad.badge_label}
</Badge>
)}
{ad.headline && (
<div className="font-bold text-4xl leading-12">
<div className="font-bold text-4xl leading-12 pointer-events-none select-none">
{ad.headline}
</div>
)}
{ad.description && (
<p className="max-w-lg">
<p className="max-w-lg pointer-events-none select-none">
{ad.description}
</p>
)}
@@ -53,7 +54,7 @@ export function Hero({ ad, onCtaClick }) {
</div>
)}
</div>
<div className="rounded-lg bg-muted w-xl aspect-video flex items-center justify-center overflow-hidden">
<div className="rounded-lg bg-muted w-xl aspect-video flex items-center justify-center overflow-hidden pointer-events-none select-none">
{imageSrc ? (
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
) : (
@@ -68,7 +69,7 @@ export function Hero({ ad, onCtaClick }) {
export function HeroSkeleton() {
return (
<div className="flex xs:flex-col lg:flex-row items-center gap-6">
<div className="flex xs:flex-col lg:flex-row items-center justify-between gap-6">
<div className="flex flex-col gap-3 w-full max-w-lg">
<Skeleton className="h-6 w-32 rounded-full" />
<Skeleton className="h-10 w-3/4" />
@@ -2,6 +2,7 @@
import { Button } from "@/components/ui/button";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { resolveAssetSrc } from "@/utils/media.util";
// ── Popup ────────────────────────────────────────────────────────────────────
/**
@@ -11,17 +12,23 @@ import ResponsiveModal from "@/components/generic/ResponsiveModal";
* to true once an active popup ad resolves from the API).
*
* Props:
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
* open — boolean, modal visibility
* onOpenChange — (open: boolean) => void
* onCtaClick — (ad, cta) => void, called when a footer CTA button is clicked
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
* open — boolean, modal visibility
* onOpenChange — (open: boolean) => void
* onCtaClick — (ad, cta) => void, called when a footer CTA button is clicked
* onDismissForever — () => void, called when the user picks "Don't show this ad again"
*/
export function Popup({ ad, open, onOpenChange, onCtaClick }) {
export function Popup({ ad, open, onOpenChange, onCtaClick, onDismissForever }) {
if (!ad) return null;
const imageSrc = ad.image?.file_url || ad.image_url || null;
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const handleDismissForever = () => {
onOpenChange?.(false);
onDismissForever?.();
};
return (
<ResponsiveModal
open={open}
@@ -45,10 +52,20 @@ export function Popup({ ad, open, onOpenChange, onCtaClick }) {
}
>
{imageSrc && (
<div className="rounded-lg bg-muted aspect-video flex items-center justify-center overflow-hidden">
<div className="rounded-lg bg-muted aspect-video flex items-center justify-center overflow-hidden pointer-events-none select-none">
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
</div>
)}
{onDismissForever && (
<button
type="button"
onClick={handleDismissForever}
className="w-fit justify-self-start rounded text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
Don't show this ad again
</button>
)}
</ResponsiveModal>
);
}
@@ -3,6 +3,7 @@
import { Megaphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { resolveAssetSrc } from "@/utils/media.util";
// ── Sidebar ──────────────────────────────────────────────────────────────────
/**
@@ -18,7 +19,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export function Sidebar({ ad, onCtaClick }) {
if (!ad) return null;
const imageSrc = ad.image?.file_url || ad.image_url || null;
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const primaryCta = ctas[0];
@@ -31,7 +32,7 @@ export function Sidebar({ ad, onCtaClick }) {
className="w-full max-w-xs rounded-lg border bg-card overflow-hidden flex flex-col cursor-pointer"
onClick={handleCardClick}
>
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden">
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden pointer-events-none select-none">
{imageSrc ? (
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
) : (
@@ -41,8 +42,8 @@ export function Sidebar({ ad, onCtaClick }) {
{(ad.headline || ad.description || primaryCta) && (
<div className="p-3 flex flex-col gap-1.5">
{ad.headline && <p className="text-sm font-medium leading-snug">{ad.headline}</p>}
{ad.description && <p className="text-xs text-muted-foreground line-clamp-2">{ad.description}</p>}
{ad.headline && <p className="text-sm font-medium leading-snug pointer-events-none select-none">{ad.headline}</p>}
{ad.description && <p className="text-xs text-muted-foreground line-clamp-2 pointer-events-none select-none">{ad.description}</p>}
{primaryCta && (
<Button
size="sm"
@@ -1,6 +1,7 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -172,11 +173,7 @@ export function AudioBlock({ content }) {
// ── States ────────────────────────────────────────────────────────────────
if (fetchLoading) {
return (
<div className="w-full rounded-xl border border-border bg-card flex items-center justify-center h-28">
<div className="w-5 h-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
</div>
);
return <MediaFallback className="w-full h-28 rounded-xl border border-border" />;
}
if (fetchError) {
@@ -8,6 +8,7 @@ import {
} from "@/components/ui/tooltip";
import { ChevronLeft, ChevronRight } from "lucide-react";
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -426,11 +427,7 @@ export function VideoBlock({ content }) {
}
if (fetchLoading) {
return (
<div className="flex items-center justify-center aspect-video rounded-lg bg-black/90">
<div className="w-10 h-10 rounded-full border-4 border-white/20 border-t-white animate-spin" />
</div>
);
return <MediaFallback className="aspect-video rounded-lg" />;
}
if (fetchError || !blobUrl) {
@@ -0,0 +1,124 @@
// components/generic/BroadcastTargetPicker.jsx
// Single-select searchable picker for notification broadcast targeting.
// Fetches the right list (task lists / courses / tier plans) based on targetType.
import { useEffect, useMemo, useState } from "react";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import api from "@/utils/api.util";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
// ─── Per-target-type data source config ───────────────────────────────────────
const TARGET_CONFIGS = {
task_list: {
fetch: () => api.get("/admin/task-lists", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []),
idKey: "task_list_id",
labelKey: "name",
placeholder: "Select a task list…",
},
course: {
fetch: () => api.get("/admin/courses/flat").then((res) => res.data?.data ?? []),
idKey: "uuid",
labelKey: "title",
placeholder: "Select a course…",
},
tier_plan: {
fetch: () => api.get("/admin/tiers", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []),
idKey: "plan_id",
labelKey: "label",
placeholder: "Select a tier plan…",
},
};
export function BroadcastTargetPicker({ targetType, value, onChange }) {
const config = TARGET_CONFIGS[targetType];
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => {
if (!config) return;
let cancelled = false;
setLoading(true);
config.fetch()
.then((data) => { if (!cancelled) setItems(Array.isArray(data) ? data : []); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetType]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q || !config) return items;
return items.filter((item) => String(item[config.labelKey] ?? "").toLowerCase().includes(q));
}, [items, query, config]);
if (!config) return null;
const selected = items.find((item) => String(item[config.idKey]) === String(value));
return (
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setQuery(""); }}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal h-9"
>
<span className="truncate">
{selected ? selected[config.labelKey] : config.placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<div className="flex items-center gap-2 border-b px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
className="flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-56 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner className="size-4" />
Loading…
</div>
) : filtered.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No results found.</p>
) : (
filtered.map((item) => {
const id = item[config.idKey];
const isSelected = String(value) === String(id);
return (
<div
key={id}
role="option"
aria-selected={isSelected}
onClick={() => { onChange(id); setOpen(false); setQuery(""); }}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm cursor-pointer select-none transition-colors hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent/50"
)}
>
<Check className={cn("size-3.5 shrink-0", isSelected ? "opacity-100" : "opacity-0")} />
<span className="truncate">{item[config.labelKey]}</span>
</div>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}
+19 -181
View File
@@ -1,187 +1,25 @@
import { useState } from "react";
import { Bell, Trophy, BookOpen, Star, CheckCircle, Copy, Check, Megaphone } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { cn } from "@/lib/utils";
import { useDateFormat } from "@/hooks/useDateFormat";
const TYPE_ICON = {
achievement: Trophy,
course: BookOpen,
milestone: Star,
task: CheckCircle,
announcement: Megaphone,
};
function NotificationIcon({ type, className }) {
const Icon = TYPE_ICON[type] ?? Bell;
return <Icon className={cn("shrink-0", className)} />;
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export default function ClientNotificationBell() {
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
useClientNotifications();
const { fmtDateTime } = useDateFormat();
const { unseenCount } = useClientNotifications();
const navigate = useNavigate();
const [selected, setSelected] = useState(null);
const [copiedCode, setCopiedCode] = useState(false);
async function handleCopyCode(code) {
await navigator.clipboard.writeText(code);
setCopiedCode(true);
setTimeout(() => setCopiedCode(false), 2000);
}
function handleOpen(open) {
if (open) fetchNotifications();
}
function handleClickNotification(n) {
if (!n.seen) markSeen(n.notification_id);
setSelected(n);
}
return (
<>
<Popover onOpenChange={handleOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="relative">
<Bell className="h-4 w-4" />
{unseenCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white leading-none">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-4 pt-3">
<span className="text-sm font-semibold">Notifications</span>
{unseenCount > 0 && (
<Button
onClick={markAllSeen}
variant="ghost"
size="sm"
>
Mark all as read
</Button>
)}
</div>
<Separator />
<ScrollArea className="h-80">
{loading && notifications.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground">Loading...</p>
) : notifications.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground">No notifications yet.</p>
) : (
<ul>
{notifications.map((n, i) => (
<li key={n.notification_id}>
<button
onClick={() => handleClickNotification(n)}
className={cn(
"w-full text-left px-4 py-3 hover:bg-muted/50 transition-colors",
!n.seen && "bg-blue-50 dark:bg-blue-950/20"
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<NotificationIcon type={n.type} className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{!n.seen && (
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
)}
<p className="text-xs font-medium truncate">{n.title}</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{n.message}</p>
<p className="text-[10px] text-muted-foreground mt-1">{timeAgo(n.createdAt)}</p>
</div>
</div>
</button>
{i < notifications.length - 1 && <Separator />}
</li>
))}
</ul>
)}
</ScrollArea>
</PopoverContent>
</Popover>
{/* Detail dialog — rendered outside the Popover so it isn't clipped */}
<Dialog open={!!selected} onOpenChange={(open) => { if (!open) { setSelected(null); setCopiedCode(false); } }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted">
<NotificationIcon
type={selected?.type}
className="h-5 w-5 text-foreground"
/>
</div>
<DialogTitle className="leading-snug">{selected?.title}</DialogTitle>
</div>
<DialogDescription className="text-sm text-foreground/80 leading-relaxed">
{selected?.message}
</DialogDescription>
</DialogHeader>
<Separator />
{selected?.data?.groupCode && (
<>
<div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Group Code
</p>
<div className="flex items-center gap-2">
<span className="flex-1 font-mono text-sm bg-muted rounded-lg px-3 py-2 truncate">
{selected.data.groupCode}
</span>
<Button
size="sm"
variant="outline"
className="shrink-0 gap-1.5"
onClick={() => handleCopyCode(selected.data.groupCode)}
>
{copiedCode
? <><Check className="size-3.5" /> Copied</>
: <><Copy className="size-3.5" /> Copy</>
}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Share this code with others so they can join your group.
</p>
</div>
<Separator />
</>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="capitalize">{selected?.type}</span>
<span>{selected ? fmtDateTime(selected.createdAt) : ""}</span>
</div>
</DialogContent>
</Dialog>
</>
);
return (
<Button
variant="outline"
size="icon"
className="relative"
onClick={() => navigate("/notifications")}
>
<Bell className="h-4 w-4" />
{unseenCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white leading-none">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
)}
</Button>
);
}
@@ -1,4 +1,5 @@
// ─── components/ArchiveDialog.jsx ────────────────────────────────────────────
import { useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -10,6 +11,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
import { AlertTriangle } from "lucide-react";
/**
* Generic archive dialog — works for any entity (users, groups, etc.)
@@ -17,10 +19,14 @@ import { Spinner } from "@/components/ui/spinner";
* Single: <ArchiveDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <ArchiveDialog ids={[1, 2, 3]} entityLabel="Group" ... />
*
* @param {Function} onArchive (id | { ids }) => Promise — called with single id or { ids }
* @param {Function} getName (entity) => string — how to display the entity name
* @param {string} entityLabel e.g. "User", "Group"
* @param {boolean} loading from whichever context the parent uses
* @param {Function} onArchive (id | { ids }) => Promise — called with single id or { ids }
* @param {Function} getName (entity) => string — how to display the entity name
* @param {string} entityLabel e.g. "User", "Group"
* @param {boolean} loading from whichever context the parent uses
* @param {Function} onImpactCheck optional — async () => { label, count }[]
* called when dialog opens (single archive only).
* Returns an array of impact lines to warn about.
* Items with count === 0 are filtered out automatically.
*/
export function ArchiveDialog({
open,
@@ -32,14 +38,32 @@ export function ArchiveDialog({
onArchive,
loading,
onSuccess,
onImpactCheck,
}) {
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const [impactLoading, setImpactLoading] = useState(false);
const [impacts, setImpacts] = useState([]);
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.name ?? entity?.email ?? "this item");
// Fetch impact when dialog opens for a single-entity archive
useEffect(() => {
if (!open || isBulk || !onImpactCheck) {
setImpacts([]);
return;
}
setImpactLoading(true);
setImpacts([]);
onImpactCheck()
.then((rows) => setImpacts((rows ?? []).filter((r) => r.count > 0)))
.catch(() => setImpacts([]))
.finally(() => setImpactLoading(false));
}, [open]);
const handleArchive = async () => {
const res = isBulk
? await onArchive({ ids })
@@ -51,6 +75,8 @@ export function ArchiveDialog({
}
};
const hasImpact = impacts.length > 0;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
@@ -58,19 +84,48 @@ export function ArchiveDialog({
<AlertDialogTitle>
Archive {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to archive{" "}
<span className="font-medium text-foreground">{displayName}</span>?{" "}
{isBulk
? "They will be deactivated and lose access immediately."
: "This will deactivate the record immediately."}
</AlertDialogDescription>
{impactLoading ? (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Spinner className="size-4" /> Checking impact…
</div>
) : (
<>
<AlertDialogDescription>
Are you sure you want to archive{" "}
<span className="font-medium text-foreground">{displayName}</span>?{" "}
{isBulk
? "They will be deactivated and lose access immediately."
: "This will deactivate the record immediately."}
</AlertDialogDescription>
{hasImpact && (
<div className="mt-3 rounded-md border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-400 font-medium text-sm">
<AlertTriangle className="h-4 w-4 shrink-0" />
This will affect active learners
</div>
<ul className="ml-5 list-disc text-sm text-amber-800 dark:text-amber-300 space-y-0.5">
{impacts.map((impact, i) => (
<li key={i}>
<span className="font-semibold">{impact.count}</span> {impact.label}
</li>
))}
</ul>
<p className="text-xs text-amber-700 dark:text-amber-400 pt-0.5">
You can still proceed — this cannot be undone without restoring the record.
</p>
</div>
)}
</>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={loading || impactLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleArchive}
disabled={loading}
disabled={loading || impactLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading && <Spinner className="size-4 mr-2" />}
@@ -80,4 +135,4 @@ export function ArchiveDialog({
</AlertDialogContent>
</AlertDialog>
);
}
}
@@ -0,0 +1,31 @@
const ROWS = [
{ md: "**bold**", out: "bold" },
{ md: "*italic*", out: "italic" },
{ md: "# Heading", out: "large heading (## and ### for smaller)" },
{ md: "[link text](https://…)", out: "a link" },
{ md: "- item", out: "bullet list" },
{ md: "1. item", out: "numbered list" },
{ md: "`code`", out: "inline code" },
{ md: "blank line", out: "starts a new paragraph" },
];
// Compact reference for the Markdown body editor — shown under the textarea
// wherever admins author Markdown that gets converted to HTML on save.
export function MarkdownCheatsheet() {
return (
<div className="rounded-md border bg-muted/40 p-3 text-xs">
<p className="font-medium text-foreground mb-2">Quick Markdown reference</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
{ROWS.map((r) => (
<div key={r.md} className="flex items-center gap-2 min-w-0">
<code className="bg-muted px-1.5 py-0.5 rounded shrink-0">{r.md}</code>
<span className="text-muted-foreground truncate">→ {r.out}</span>
</div>
))}
</div>
<p className="text-muted-foreground mt-2">
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens still work anywhere in the text.
</p>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
// components/generic/MediaFallback.jsx
//
// Mandatory fallback shown wherever a media element (video/audio/image) is
// still resolving its source — replaces ad-hoc spinners so every player has
// the same loading state.
export function MediaFallback({ className = "" }) {
return (
<div
className={`flex items-center justify-center overflow-hidden ${className}`}
style={{ background: "#FBF7F0" }}
>
<img
src="/media-fallback-patient-tv.svg"
alt="Media is loading"
className="w-full h-full object-contain"
/>
</div>
);
}
@@ -0,0 +1,96 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Copy, Check, ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { useDateFormat } from "@/hooks/useDateFormat";
import { NotificationIcon, getTypeAccent, resolveNotificationLink } from "@/components/generic/notificationDisplay";
export default function NotificationDetailDialog({ notification, onOpenChange }) {
const { fmtDateTime } = useDateFormat();
const navigate = useNavigate();
const [copiedCode, setCopiedCode] = useState(false);
const [navigating, setNavigating] = useState(false);
async function handleCopyCode(code) {
await navigator.clipboard.writeText(code);
setCopiedCode(true);
setTimeout(() => setCopiedCode(false), 2000);
}
const link = notification ? resolveNotificationLink(notification.type, notification.data) : null;
async function handleGo() {
if (!link) return;
setNavigating(true);
await link.go(navigate);
setNavigating(false);
onOpenChange(false);
}
return (
<Dialog open={!!notification} onOpenChange={(open) => { if (!open) { onOpenChange(false); setCopiedCode(false); } }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className={`flex h-9 w-9 items-center justify-center rounded-full ${getTypeAccent(notification?.type)}`}>
<NotificationIcon
type={notification?.type}
className="h-5 w-5"
/>
</div>
<DialogTitle className="leading-snug">{notification?.title}</DialogTitle>
</div>
<DialogDescription className="text-sm text-foreground/80 leading-relaxed">
{notification?.message}
</DialogDescription>
</DialogHeader>
<Separator />
{notification?.data?.groupCode && (
<>
<div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Group Code
</p>
<div className="flex items-center gap-2">
<span className="flex-1 font-mono text-sm bg-muted rounded-lg px-3 py-2 truncate">
{notification.data.groupCode}
</span>
<Button
size="sm"
variant="outline"
className="shrink-0 gap-1.5"
onClick={() => handleCopyCode(notification.data.groupCode)}
>
{copiedCode
? <><Check className="size-3.5" /> Copied</>
: <><Copy className="size-3.5" /> Copy</>
}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Share this code with others so they can join your group.
</p>
</div>
<Separator />
</>
)}
{link && (
<Button className="w-full gap-1.5" onClick={handleGo} disabled={navigating}>
{navigating ? <Spinner className="size-4" /> : <>{link.label} <ArrowRight className="size-3.5" /></>}
</Button>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="capitalize">{notification?.type?.replace("_", " ")}</span>
<span>{notification ? fmtDateTime(notification.createdAt) : ""}</span>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,142 @@
// components/generic/PlacementSkeleton.jsx
import { cn } from "@/lib/utils";
import { PLACEMENT_LAYOUTS } from "@/data/placementLayouts.data";
// ── Block renderers ────────────────────────────────────────────────────────
const BAR_HEIGHT = {
sm: "h-4",
md: "h-8",
lg: "h-14",
xl: "h-16",
};
function HighlightTag({ label }) {
return (
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-primary text-center px-1 leading-tight">
{label}
</span>
);
}
function Nav() {
return (
<div className="h-5 rounded bg-muted border flex items-center gap-1 px-2 shrink-0">
<span className="size-1.5 rounded-full bg-muted-foreground/30" />
<span className="size-1.5 rounded-full bg-muted-foreground/30" />
</div>
);
}
function Bar({ size = "md", highlight, label }) {
return (
<div
className={cn(
"relative rounded border shrink-0",
BAR_HEIGHT[size] ?? BAR_HEIGHT.md,
highlight ? "bg-primary/15 border-2 border-primary" : "bg-muted border-border"
)}
>
{highlight && label && <HighlightTag label={label} />}
</div>
);
}
function Filters() {
return (
<div className="flex gap-1.5 shrink-0">
{[1, 2, 3].map((i) => (
<span key={i} className="h-3 w-8 rounded-full bg-muted border" />
))}
</div>
);
}
function Grid({ label }) {
return (
<div className="flex flex-col gap-1 shrink-0">
<div className="flex gap-1.5">
{[1, 2, 3, 4].map((i) => (
<span key={i} className="h-6 flex-1 rounded bg-muted border" />
))}
</div>
{label && <span className="text-[9px] text-muted-foreground text-center">{label}</span>}
</div>
);
}
function ListBlock({ label }) {
return (
<div className="flex flex-col gap-1 shrink-0">
{[1, 2].map((i) => (
<span key={i} className="h-3 rounded bg-muted border" />
))}
{label && <span className="text-[9px] text-muted-foreground text-center">{label}</span>}
</div>
);
}
function Row({ columns = [] }) {
return (
<div className="flex gap-2 shrink-0 flex-1">
{columns.map((col, i) => (
<div key={i} className={cn("relative rounded border h-16", col.width ?? "flex-1", col.highlight ? "bg-primary/15 border-2 border-primary" : "bg-muted border-border")}>
{col.highlight ? (
<HighlightTag label={col.label} />
) : (
col.label && (
<span className="absolute inset-0 flex items-center justify-center text-[9px] text-muted-foreground text-center px-1">
{col.label}
</span>
)
)}
</div>
))}
</div>
);
}
function Block({ block }) {
switch (block.kind) {
case "nav": return <Nav />;
case "bar": return <Bar size={block.size} highlight={block.highlight} label={block.label} />;
case "filters": return <Filters />;
case "grid": return <Grid label={block.label} />;
case "list": return <ListBlock label={block.label} />;
case "row": return <Row columns={block.columns} />;
default: return null;
}
}
// ── PlacementSkeleton ───────────────────────────────────────────────────────
/**
* Lightweight wireframe preview of where an advertisement placement lands on
* its page. Purely a visual aid for the admin creation wizard — not a real
* screenshot, so it never goes stale when the real page's styling changes.
*
* Props:
* placement — a placement registry key, e.g. "dashboard.hero"
*/
export function PlacementSkeleton({ placement }) {
const layout = PLACEMENT_LAYOUTS[placement];
if (!layout) return null;
return (
<div className="relative w-full aspect-video rounded-lg border bg-card p-3 overflow-hidden">
<div className={cn("h-full flex flex-col gap-2", layout.overlay && "opacity-40")}>
{layout.blocks.map((block, i) => (
<Block key={i} block={block} />
))}
</div>
{layout.overlay && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-1/2 h-1/3 rounded-lg bg-primary/15 border-2 border-primary shadow-sm">
<HighlightTag label={layout.overlay.label} />
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,98 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Send } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useAdminEmailBroadcasts } from "@/contexts/AdminEmailBroadcastContext";
// Reused from Notification Broadcasts so admins pick an audience the same way
// everywhere — same target types, same picker for task list / course / tier plan.
export function SendEmailBroadcastDialog({ open, onOpenChange, template }) {
const navigate = useNavigate();
const { createBroadcast, loading } = useAdminEmailBroadcasts();
const [targetType, setTargetType] = useState("");
const [targetId, setTargetId] = useState(null);
const [error, setError] = useState("");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const reset = () => { setTargetType(""); setTargetId(null); setError(""); };
const handleSend = async () => {
if (!targetType) return setError("Please select an audience.");
if (needsTarget && !targetId) return setError("Please select a specific target.");
setError("");
const result = await createBroadcast({
email_template_id: template.email_template_id,
target_type: targetType,
target_id: needsTarget ? targetId : null,
});
if (result) {
reset();
onOpenChange(false);
navigate("/admin/email-broadcasts");
}
};
return (
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) reset(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Send to Recipients</DialogTitle>
<DialogDescription>
Send <span className="font-semibold text-foreground">{template?.label}</span> as a real email.
Delivery is paced in the background — this won't block or time out.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Audience</Label>
<Select value={targetType} onValueChange={(v) => { setTargetType(v); setTargetId(null); }}>
<SelectTrigger><SelectValue placeholder="Select an audience" /></SelectTrigger>
<SelectContent>
{TARGET_TYPE_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
{targetType && (
<p className="text-xs text-muted-foreground">{TARGET_TYPE_MAP[targetType]?.description}</p>
)}
</div>
{needsTarget && (
<div className="space-y-1.5">
<Label>Target</Label>
<BroadcastTargetPicker targetType={targetType} value={targetId} onChange={setTargetId} />
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button onClick={handleSend} disabled={loading}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
Send
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ function ThemeOption({ value, label, icon: Icon, active, onClick }) {
return (
<button
onClick={() => onClick(value)}
className={`flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all cursor-pointer w-full
className={`relative flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all cursor-pointer w-full
${active
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40'
@@ -0,0 +1,103 @@
import { Bell, Trophy, BookOpen, Star, CheckCircle, Megaphone, Zap, ClipboardList } from "lucide-react";
import { cn } from "@/lib/utils";
import api from "@/utils/api.util";
const TYPE_ICON = {
achievement: Trophy,
course: BookOpen,
milestone: Star,
task: CheckCircle,
announcement: Megaphone,
tier_expired: Zap,
assessment: ClipboardList,
};
const TYPE_ACCENT = {
achievement: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/40 dark:text-yellow-400",
course: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400",
milestone: "bg-purple-100 text-purple-700 dark:bg-purple-950/40 dark:text-purple-400",
task: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400",
announcement: "bg-indigo-100 text-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-400",
tier_expired: "bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400",
assessment: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400",
};
export function NotificationIcon({ type, className }) {
const Icon = TYPE_ICON[type] ?? Bell;
return <Icon className={cn("shrink-0", className)} />;
}
export function getTypeAccent(type) {
return TYPE_ACCENT[type] ?? "bg-muted text-foreground";
}
export function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
// Resolves a course uuid to its numeric course_id — client course pages route by course_id, not uuid.
async function goToCourse(navigate, courseUuid) {
try {
const { data } = await api.get(`/client/courses/uuid/${courseUuid}`);
const courseId = data?.data?.course_id;
if (courseId) navigate(`/course/${courseId}`);
} catch {
// silent — dialog just stays open if the course can't be resolved
}
}
/**
* Per-type deep-link registry. Returns null when the notification carries no
* resolvable target (e.g. old rows created before ids were tracked).
*
* @returns {{ label: string, go: (navigate: Function) => (void | Promise<void>) } | null}
*/
export function resolveNotificationLink(type, data) {
if (!data) return null;
switch (type) {
case "course":
return data.courseUuid
? { label: "Go to course", go: (navigate) => goToCourse(navigate, data.courseUuid) }
: null;
case "assessment":
return data.courseUuid
? { label: "Go to course", go: (navigate) => goToCourse(navigate, data.courseUuid) }
: null;
case "task":
return (data.groupId && data.taskListId)
? { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.taskListId}`) }
: null;
case "tier_expired":
return data.planId
? { label: "Renew plan", go: (navigate) => navigate(`/plans/view/${data.planId}`) }
: { label: "View plans", go: (navigate) => navigate("/plans") };
case "announcement":
if (data.groupId && data.groupCode) {
return { label: "View my group", go: (navigate) => navigate(`/group/${data.groupId}`) };
}
if (data.targetType === "course" && data.targetId) {
return { label: "Go to course", go: (navigate) => goToCourse(navigate, data.targetId) };
}
if (data.targetType === "tier_plan" && data.targetId) {
return { label: "View plan", go: (navigate) => navigate(`/plans/view/${data.targetId}`) };
}
if (data.targetType === "task_list" && data.groupId && data.targetId) {
return { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.targetId}`) };
}
return null;
default:
return null;
}
}