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
+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400" width="640" height="400" font-family="Helvetica, Arial, sans-serif">
<rect width="640" height="400" rx="24" class="fb-bg" fill="#FBF7F0"></rect>
<line x1="320" y1="92" x2="284" y2="52" class="fb-ink-s" stroke="#2B2926" stroke-width="4" stroke-linecap="round"></line>
<line x1="320" y1="92" x2="360" y2="48" class="fb-ink-s" stroke="#2B2926" stroke-width="4" stroke-linecap="round"></line>
<circle cx="284" cy="52" r="6" fill="#E8684A">
</circle>
<circle cx="360" cy="48" r="6" fill="#2FA9A2"></circle>
<rect x="228" y="88" width="184" height="136" rx="18" class="fb-ink" fill="#2B2926"></rect>
<rect x="242" y="102" width="156" height="108" rx="10" class="fb-bg" fill="#FBF7F0"></rect>
<ellipse cx="296" cy="148" rx="8" ry="8" class="fb-ink" fill="#2B2926">
</ellipse>
<ellipse cx="344" cy="148" rx="8" ry="8" class="fb-ink" fill="#2B2926">
</ellipse>
<rect x="308" y="176" width="24" height="4" rx="2" class="fb-ink" fill="#2B2926"></rect>
<line x1="288" y1="224" x2="276" y2="244" class="fb-ink-s" stroke="#2B2926" stroke-width="4" stroke-linecap="round"></line>
<line x1="352" y1="224" x2="364" y2="244" class="fb-ink-s" stroke="#2B2926" stroke-width="4" stroke-linecap="round"></line>
<text x="320" y="318" text-anchor="middle" font-size="26" font-weight="bold" class="fb-ink" fill="#2B2926">Media is currently loading...</text>
<text x="320" y="348" text-anchor="middle" font-size="16" class="fb-muted" fill="#6F6A63">If media did not load correctly just reload a page again..</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+6 -9
View File
@@ -2,7 +2,6 @@ import { useEffect } from 'react';
import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext';
import { CurrencyPreferenceProvider } from './contexts/CurrencyPreferenceContext';
import { Helmet, HelmetProvider } from "react-helmet-async";
import { TooltipProvider } from './components/ui/tooltip';
import { setAuthInterceptor } from './utils/api.util';
@@ -82,15 +81,13 @@ export default function App() {
<meta name="twitter:description" content="This is still in development phase. Come back soon." />
<meta name="twitter:image" content="https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC" />
</Helmet>
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
<DateTimePreferenceProvider>
<CurrencyPreferenceProvider>
<TooltipProvider delayDuration={300}>
<AuthProvider>
<AppWithAuth />
</AuthProvider>
</TooltipProvider>
</CurrencyPreferenceProvider>
<TooltipProvider delayDuration={300}>
<AuthProvider>
<AppWithAuth />
</AuthProvider>
</TooltipProvider>
</DateTimePreferenceProvider>
</ThemeProvider>
</HelmetProvider>
+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;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
const AdminAchievementsContext = createContext(null);
export function useAdminAchievements() {
const ctx = useContext(AdminAchievementsContext);
if (!ctx) throw new Error("useAdminAchievements must be used inside AdminAchievementsProvider");
return ctx;
}
export function AdminAchievementsProvider({ children }) {
const [achievements, setAchievements] = useState([]);
const [achievement, setAchievement] = useState(null);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
setLoading(true);
try { return await fn(); }
catch (err) {
toast.error(err?.response?.data?.message ?? "Something went wrong.");
return null;
} finally { setLoading(false); }
}, []);
const fetchAchievements = useCallback(() =>
request(async () => {
const { data } = await api.get("/admin/achievements");
setAchievements(data.data ?? []);
return data.data;
}), [request]);
const fetchAchievement = useCallback((id) =>
request(async () => {
const { data } = await api.get(`/admin/achievements/${id}`);
setAchievement(data.data ?? null);
return data.data;
}), [request]);
const createAchievement = useCallback((payload) =>
request(async () => {
const { data } = await api.post("/admin/achievements", payload);
toast.success("Achievement created.");
return data.data;
}), [request]);
const updateAchievement = useCallback((id, payload) =>
request(async () => {
const { data } = await api.put(`/admin/achievements/${id}`, payload);
setAchievements((prev) =>
prev.map((a) => (String(a.achievement_definition_id) === String(id) ? data.data : a))
);
if (achievement && String(achievement.achievement_definition_id) === String(id)) setAchievement(data.data);
toast.success("Achievement updated.");
return data.data;
}), [request, achievement]);
const deleteAchievement = useCallback((id) =>
request(async () => {
await api.delete(`/admin/achievements/${id}`);
setAchievements((prev) => prev.filter((a) => String(a.achievement_definition_id) !== String(id)));
toast.success("Achievement deleted.");
return true;
}), [request]);
return (
<AdminAchievementsContext.Provider value={{
achievements, achievement, loading,
fetchAchievements, fetchAchievement,
createAchievement, updateAchievement, deleteAchievement,
}}>
{children}
</AdminAchievementsContext.Provider>
);
}
+102 -4
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useState } from "react";
import { createContext, useCallback, useContext, useRef, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
@@ -20,6 +20,16 @@ const PAGINATION_INIT = {
hasNextPage: false,
};
// No Redis yet — these caches are plain in-memory (per browser tab, cleared on
// refresh) to absorb the repeated open/close traffic pickers like
// AssetPickerSheet generate against Postgres and the media-token endpoint.
const LIST_CACHE_TTL_MS = 20_000; // short: just enough to survive rapid open/close flapping
const MEDIA_TOKEN_TTL_MS = 30 * 60 * 1000; // mirrors TOKEN_TTL_SEC in media.controller.js
const MEDIA_TOKEN_REFRESH_MARGIN_MS = 2 * 60 * 1000; // re-mint a bit before real expiry
const cacheKeyFor = (scope, { page, limit, filters, sort }) =>
`${scope}:${JSON.stringify({ page, limit, filters, sort })}`;
export function AssetsProvider({ children }) {
const [assets, setAssets] = useState([]);
const [attributes, setAttributes] = useState([]);
@@ -27,6 +37,32 @@ export function AssetsProvider({ children }) {
const [selectedAsset, setSelectedAsset] = useState(null);
const [loading, setLoading] = useState(false);
// { [asset_id]: { token, thumbnail_url, issuedAt } } — shared across every
// picker instance so tokens survive sheet open/close for their full TTL.
const [mediaTokens, setMediaTokens] = useState({});
const mediaTokensRef = useRef({});
const listCacheRef = useRef(new Map());
const invalidateListCache = () => listCacheRef.current.clear();
// Seeds mediaTokens from stream_token/thumbnail_url fields the backend now
// embeds directly in S3 rows of GET /admin/assets — so getMediaTokens (called
// right after fetchAssets by pickers/tables) finds them already cached and
// skips the batch round-trip instead of re-requesting tokens it just got.
const seedMediaTokensFromRows = (rows = []) => {
const issuedAt = Date.now();
const next = {};
for (const row of rows) {
if (row.stream_token) {
next[String(row.asset_id)] = { token: row.stream_token, thumbnail_url: row.thumbnail_url ?? null, issuedAt };
}
}
if (Object.keys(next).length) {
mediaTokensRef.current = { ...mediaTokensRef.current, ...next };
setMediaTokens(mediaTokensRef.current);
}
};
const request = useCallback(async (fn) => {
setLoading(true);
try {
@@ -41,9 +77,22 @@ export function AssetsProvider({ children }) {
}, []);
// ─── GET /api/admin/assets ────────────────────────────────────────────────
// Cached per (page, limit, filters, sort) for LIST_CACHE_TTL_MS so toggling
// a picker like AssetPickerSheet open/closed doesn't re-hit Postgres for the
// same query within the TTL window. Pass force: true to bypass the cache.
const fetchAssets = useCallback(
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
request(async () => {
({ page = 1, limit = 10, filters = [], sort = [], force = false } = {}) => {
const key = cacheKeyFor("assets", { page, limit, filters, sort });
const cached = listCacheRef.current.get(key);
if (!force && cached && Date.now() - cached.fetchedAt < LIST_CACHE_TTL_MS) {
setAssets(cached.assets);
setPagination(cached.pagination);
setAttributes(cached.attributes);
seedMediaTokensFromRows(cached.assets);
return Promise.resolve(cached.raw);
}
return request(async () => {
const { data } = await api.get("/admin/assets", {
params: {
page, limit,
@@ -57,12 +106,53 @@ export function AssetsProvider({ children }) {
setAssets(result?.data ?? []);
setPagination(result?.pagination ?? PAGINATION_INIT);
setAttributes(result.attributes);
seedMediaTokensFromRows(result?.data);
listCacheRef.current.set(key, {
assets: result?.data ?? [],
pagination: result?.pagination ?? PAGINATION_INIT,
attributes: result.attributes,
raw: data.data,
fetchedAt: Date.now(),
});
return data.data;
}),
});
},
[request]
);
// ─── POST /api/admin/media/tokens (batch) ────────────────────────────────
// Skips any asset_id whose cached token is still within its TTL (minus a
// safety margin) instead of re-minting a fresh JWT/presigned URL every time
// a picker reopens. Shared across all picker instances via context state.
const getMediaTokens = useCallback((assetIds = []) => {
const now = Date.now();
const missing = assetIds
.map(String)
.filter((id) => {
const cached = mediaTokensRef.current[id];
return !cached || (now - cached.issuedAt) > (MEDIA_TOKEN_TTL_MS - MEDIA_TOKEN_REFRESH_MARGIN_MS);
});
if (!missing.length) return Promise.resolve(mediaTokensRef.current);
return api.post("/admin/media/tokens", { asset_ids: missing }).then(({ data }) => {
const tokens = data.data?.tokens ?? {};
const thumbnails = data.data?.thumbnails ?? {};
const issuedAt = Date.now();
const next = {};
for (const [id, token] of Object.entries(tokens)) {
next[id] = { token, thumbnail_url: thumbnails[id] ?? null, issuedAt };
}
mediaTokensRef.current = { ...mediaTokensRef.current, ...next };
setMediaTokens(mediaTokensRef.current);
return mediaTokensRef.current;
});
}, []);
// ─── GET /api/admin/assets/:assetId ───────────────────────────────────────
const fetchAsset = useCallback(
(assetId) =>
@@ -110,6 +200,7 @@ export function AssetsProvider({ children }) {
const asset = res.data?.data?.data ?? null;
if (asset) {
setAssets((prev) => [asset, ...prev]);
invalidateListCache();
toast.success("Asset uploaded successfully.");
}
return res.data;
@@ -135,6 +226,7 @@ export function AssetsProvider({ children }) {
if (asset) {
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
setSelectedAsset(asset);
invalidateListCache();
toast.success("Asset updated successfully.");
}
return res.data;
@@ -151,6 +243,7 @@ export function AssetsProvider({ children }) {
});
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
invalidateListCache();
toast.success("Asset archived.");
return res.data;
}),
@@ -165,6 +258,7 @@ export function AssetsProvider({ children }) {
data: { ids, deletedBy },
});
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache();
toast.success(`${ids.length} asset(s) archived.`);
return res.data;
}),
@@ -179,6 +273,7 @@ export function AssetsProvider({ children }) {
const asset = res.data?.data?.data ?? null;
if (asset) {
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
invalidateListCache();
toast.success("Asset restored.");
}
return res.data;
@@ -192,6 +287,7 @@ export function AssetsProvider({ children }) {
request(async () => {
const res = await api.patch("/admin/assets/bulk-restore", { ids });
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache();
toast.success(`${ids.length} asset(s) restored.`);
return res.data;
}),
@@ -215,6 +311,8 @@ export function AssetsProvider({ children }) {
pagination,
selectedAsset,
loading,
mediaTokens,
getMediaTokens,
setPagination,
setSelectedAsset,
fetchAssets,
+42 -2
View File
@@ -230,8 +230,22 @@ export function CoursesProvider({ children }) {
// =========================================================================
const fetchUnits = useCallback(
(courseId, params = {}) =>
paginatedGet(`${BASE}/${courseId}/units`, setUnits, params),
(courseId, params = {}) => {
const mergeQuiz = (incoming) =>
setUnits((prev) => {
if (!prev.length) return incoming;
const prevMap = Object.fromEntries(prev.map((u) => [u.unit_id, u]));
return incoming.map((u) => {
const old = prevMap[u.unit_id];
return {
...u,
quiz: u.quiz ?? old?.quiz ?? null,
quiz_id: u.quiz_id ?? old?.quiz_id ?? null,
};
});
});
return paginatedGet(`${BASE}/${courseId}/units`, mergeQuiz, params);
},
[paginatedGet],
);
@@ -470,6 +484,18 @@ export function CoursesProvider({ children }) {
[request],
);
const bulkSyncQuizQuestions = useCallback(
(courseId, unitId, quizId, questions, updatedBy) =>
request(async () => {
const { data } = await api.put(`${BASE}/${courseId}/units/${unitId}/quiz/${quizId}/questions/bulk-sync`, { questions, updatedBy });
const result = data?.data?.data ?? [];
setQuestions(result);
toast.success("Quiz saved.");
return data;
}),
[request],
);
const deleteQuizQuestion = useCallback(
(courseId, unitId, quizId, questionId, deletedBy) =>
request(async () => {
@@ -796,6 +822,18 @@ export function CoursesProvider({ children }) {
[request],
);
const bulkSyncAssessmentQuestions = useCallback(
(courseId, assessmentId, questions, updatedBy) =>
request(async () => {
const { data } = await api.put(`${BASE}/${courseId}/assessment/${assessmentId}/questions/bulk-sync`, { questions, updatedBy });
const result = data?.data?.data ?? [];
setQuestions(result);
toast.success("Assessment saved.");
return data;
}),
[request],
);
const deleteAssessmentQuestion = useCallback(
(courseId, assessmentId, questionId, deletedBy) =>
request(async () => {
@@ -1061,6 +1099,7 @@ export function CoursesProvider({ children }) {
updateQuizQuestion,
deleteQuizQuestion,
bulkArchiveQuizQuestions,
bulkSyncQuizQuestions,
// ── quiz question archives & restore ───────────────────────────────────
fetchArchivedQuizQuestion,
@@ -1107,6 +1146,7 @@ export function CoursesProvider({ children }) {
updateAssessmentQuestion,
deleteAssessmentQuestion,
bulkArchiveAssessmentQuestions,
bulkSyncAssessmentQuestions,
// ── assessment question archives & restore ─────────────────────────────
fetchArchivedAssessmentQuestion,
@@ -0,0 +1,74 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
const AdminEmailBroadcastContext = createContext(null);
export function useAdminEmailBroadcasts() {
const ctx = useContext(AdminEmailBroadcastContext);
if (!ctx) throw new Error("useAdminEmailBroadcasts must be used inside AdminEmailBroadcastProvider");
return ctx;
}
export function AdminEmailBroadcastProvider({ children }) {
const [broadcasts, setBroadcasts] = useState([]);
const [broadcast, setBroadcast] = useState(null);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
setLoading(true);
try { return await fn(); }
catch (err) {
toast.error(err?.response?.data?.message ?? "Something went wrong.");
return null;
} finally { setLoading(false); }
}, []);
// Silent variant for polling — no loading spinner flicker, no toast noise on transient failures.
const fetchBroadcastsQuiet = useCallback(async () => {
try {
const { data } = await api.get("/admin/email-broadcasts");
setBroadcasts(data.data ?? []);
return data.data;
} catch { return null; }
}, []);
const fetchBroadcasts = useCallback(() =>
request(async () => {
const { data } = await api.get("/admin/email-broadcasts");
setBroadcasts(data.data ?? []);
return data.data;
}), [request]);
const fetchBroadcast = useCallback((id) =>
request(async () => {
const { data } = await api.get(`/admin/email-broadcasts/${id}`);
setBroadcast(data.data ?? null);
return data.data;
}), [request]);
const createBroadcast = useCallback((payload) =>
request(async () => {
const { data } = await api.post("/admin/email-broadcasts", payload);
toast.success(data.message ?? "Broadcast queued.");
return data.data;
}), [request]);
const cancelBroadcast = useCallback((id) =>
request(async () => {
const { data } = await api.patch(`/admin/email-broadcasts/${id}/cancel`);
setBroadcasts((prev) => prev.map((b) => (String(b.email_broadcast_id) === String(id) ? data.data : b)));
toast.success("Broadcast canceled.");
return data.data;
}), [request]);
return (
<AdminEmailBroadcastContext.Provider value={{
broadcasts, broadcast, loading,
fetchBroadcasts, fetchBroadcastsQuiet, fetchBroadcast,
createBroadcast, cancelBroadcast,
}}>
{children}
</AdminEmailBroadcastContext.Provider>
);
}
@@ -0,0 +1,76 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
const AdminEmailTemplateContext = createContext(null);
export function useAdminEmailTemplates() {
const ctx = useContext(AdminEmailTemplateContext);
if (!ctx) throw new Error("useAdminEmailTemplates must be used inside AdminEmailTemplateProvider");
return ctx;
}
export function AdminEmailTemplateProvider({ children }) {
const [templates, setTemplates] = useState([]);
const [template, setTemplate] = useState(null);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
setLoading(true);
try { return await fn(); }
catch (err) {
toast.error(err?.response?.data?.message ?? "Something went wrong.");
return null;
} finally { setLoading(false); }
}, []);
const fetchTemplates = useCallback(() =>
request(async () => {
const { data } = await api.get("/admin/email-templates");
setTemplates(data.data ?? []);
return data.data;
}), [request]);
const fetchTemplate = useCallback((id) =>
request(async () => {
const { data } = await api.get(`/admin/email-templates/${id}`);
setTemplate(data.data ?? null);
return data.data;
}), [request]);
const createTemplate = useCallback((payload) =>
request(async () => {
const { data } = await api.post("/admin/email-templates", payload);
toast.success("Email template created.");
return data.data;
}), [request]);
const updateTemplate = useCallback((id, payload) =>
request(async () => {
const { data } = await api.put(`/admin/email-templates/${id}`, payload);
setTemplates((prev) =>
prev.map((t) => (String(t.email_template_id) === String(id) ? data.data : t))
);
if (template && String(template.email_template_id) === String(id)) setTemplate(data.data);
toast.success("Email template updated.");
return data.data;
}), [request, template]);
const deleteTemplate = useCallback((id) =>
request(async () => {
await api.delete(`/admin/email-templates/${id}`);
setTemplates((prev) => prev.filter((t) => String(t.email_template_id) !== String(id)));
toast.success("Email template deleted.");
return true;
}), [request]);
return (
<AdminEmailTemplateContext.Provider value={{
templates, template, loading,
fetchTemplates, fetchTemplate,
createTemplate, updateTemplate, deleteTemplate,
}}>
{children}
</AdminEmailTemplateContext.Provider>
);
}
@@ -0,0 +1,221 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
const NotificationBroadcastsContext = createContext(null);
export function useNotificationBroadcasts() {
const ctx = useContext(NotificationBroadcastsContext);
if (!ctx) throw new Error("useNotificationBroadcasts must be used within a NotificationBroadcastsProvider");
return ctx;
}
// ─── Initial States ────────────────────────────────────────────────────────────
const PAGINATION_INIT = {
page: 1,
limit: 10,
totalRecords: 0,
totalPages: 0,
hasPrevPage: false,
hasNextPage: false,
};
export function NotificationBroadcastsProvider({ children }) {
const [broadcasts, setBroadcasts] = useState([]);
const [attributes, setAttributes] = useState([]);
const [pagination, setPagination] = useState(PAGINATION_INIT);
const [selectedBroadcast, setSelectedBroadcast] = useState(null);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
setLoading(true);
try {
return await fn();
} catch (err) {
const message = err?.response?.data?.message ?? "Something went wrong.";
toast.error(message);
return null;
} finally {
setLoading(false);
}
}, []);
// ─── GET /api/admin/notification-broadcasts ────────────────────────────────
const fetchBroadcasts = useCallback(
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
request(async () => {
const { data } = await api.get("/admin/notification-broadcasts", {
params: {
page, limit,
filters: filters.length ? JSON.stringify(filters) : undefined,
sort: sort.length ? JSON.stringify(sort) : undefined,
},
});
const result = data?.data;
setBroadcasts(result?.data ?? []);
setPagination(result?.pagination ?? PAGINATION_INIT);
setAttributes(result.attributes);
return data.data;
}),
[request]
);
// ─── GET /api/admin/notification-broadcasts/:broadcastId ──────────────────
const fetchBroadcast = useCallback(
(broadcastId) =>
request(async () => {
const res = await api.get(`/admin/notification-broadcasts/${broadcastId}`);
setSelectedBroadcast(res.data?.data?.data ?? null);
return res.data;
}),
[request]
);
// ─── GET /api/admin/notification-broadcasts/archived ───────────────────────
const fetchArchivedBroadcasts = useCallback(
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
request(async () => {
const { data } = await api.get("/admin/notification-broadcasts/archived", {
params: {
page, limit,
filters: filters.length ? JSON.stringify(filters) : undefined,
sort: sort.length ? JSON.stringify(sort) : undefined,
},
});
const final_data = data?.data;
setBroadcasts(final_data?.data ?? []);
setPagination(final_data?.pagination ?? PAGINATION_INIT);
setAttributes(final_data.attributes);
return data;
}),
[request]
);
// ─── POST /api/admin/notification-broadcasts ───────────────────────────────
const createBroadcast = useCallback(
(fields) =>
request(async () => {
const res = await api.post("/admin/notification-broadcasts", fields);
const broadcast = res.data?.data?.data ?? null;
if (broadcast) {
setBroadcasts((prev) => [broadcast, ...prev]);
toast.success("Notification broadcast created.");
}
return res.data;
}),
[request]
);
// ─── PATCH /api/admin/notification-broadcasts/:broadcastId ────────────────
const updateBroadcast = useCallback(
(broadcastId, fields) =>
request(async () => {
const res = await api.patch(`/admin/notification-broadcasts/${broadcastId}`, fields);
const broadcast = res.data?.data?.data ?? null;
if (broadcast) {
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
setSelectedBroadcast(broadcast);
toast.success("Notification broadcast updated.");
}
return res.data;
}),
[request]
);
// ─── PATCH /api/admin/notification-broadcasts/:broadcastId/send ───────────
const sendBroadcast = useCallback(
(broadcastId) =>
request(async () => {
const res = await api.patch(`/admin/notification-broadcasts/${broadcastId}/send`);
const broadcast = res.data?.data?.data ?? null;
if (broadcast) {
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
setSelectedBroadcast(broadcast);
toast.success("Notification broadcast sent.");
}
return res.data;
}),
[request]
);
// ─── DELETE /api/admin/notification-broadcasts/:broadcastId ───────────────
const archiveBroadcast = useCallback(
(broadcastId, { deletedBy } = {}) =>
request(async () => {
const res = await api.delete(`/admin/notification-broadcasts/${broadcastId}`, {
data: { deletedBy },
});
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
setSelectedBroadcast((prev) => (prev?.broadcast_id === broadcastId ? null : prev));
toast.success("Notification broadcast archived.");
return res.data;
}),
[request]
);
// ─── DELETE /api/admin/notification-broadcasts/bulk ────────────────────────
const archiveBroadcasts = useCallback(
({ ids }, { deletedBy } = {}) =>
request(async () => {
const res = await api.delete("/admin/notification-broadcasts/bulk", {
data: { ids, deletedBy },
});
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
toast.success(`${ids.length} notification broadcast(s) archived.`);
return res.data;
}),
[request]
);
// ─── PATCH /api/admin/notification-broadcasts/:broadcastId/restore ────────
const restoreBroadcast = useCallback(
(broadcastId) =>
request(async () => {
const res = await api.patch(`/admin/notification-broadcasts/${broadcastId}/restore`);
const broadcast = res.data?.data?.data ?? null;
if (broadcast) {
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
toast.success("Notification broadcast restored.");
}
return res.data;
}),
[request]
);
// ─── PATCH /api/admin/notification-broadcasts/bulk-restore ─────────────────
const restoreBroadcasts = useCallback(
({ ids }) =>
request(async () => {
const res = await api.patch("/admin/notification-broadcasts/bulk-restore", { ids });
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
toast.success(`${ids.length} notification broadcast(s) restored.`);
return res.data;
}),
[request]
);
return (
<NotificationBroadcastsContext.Provider value={{
broadcasts,
attributes,
pagination,
selectedBroadcast,
loading,
setPagination,
setSelectedBroadcast,
fetchBroadcasts,
fetchBroadcast,
fetchArchivedBroadcasts,
createBroadcast,
updateBroadcast,
sendBroadcast,
archiveBroadcast,
archiveBroadcasts,
restoreBroadcast,
restoreBroadcasts,
}}>
{children}
</NotificationBroadcastsContext.Provider>
);
}
+198 -13
View File
@@ -1,8 +1,26 @@
import { createContext, useCallback, useContext, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Button } from "@/components/ui/button";
import { useProfile } from "@/contexts/ProfileProvider";
const ClientAdvertisementsContext = createContext(null);
// After this many clicks on the same ad in one browser session, further clicks
// are held behind a confirmation dialog instead of following through straight
// away — guards against accidental/rapid repeat clicks inflating ad clicks.
const CLICK_LIMIT = 3;
const CLICK_STORAGE_KEY = "ad_click_counts";
function loadClickCounts() {
try {
return JSON.parse(sessionStorage.getItem(CLICK_STORAGE_KEY)) || {};
} catch {
return {};
}
}
export function useClientAdvertisements() {
const ctx = useContext(ClientAdvertisementsContext);
if (!ctx) throw new Error("useClientAdvertisements must be used within a ClientAdvertisementsProvider");
@@ -10,28 +28,104 @@ export function useClientAdvertisements() {
}
export function ClientAdvertisementsProvider({ children }) {
// Keyed by type so hero + popup (or any combo) can be fetched independently
// without clobbering each other: { hero: {...}, popup: {...} }
const navigate = useNavigate();
const { profile, getProfile, updateProfile } = useProfile();
// Keyed by placement so multiple slots on the same page (e.g. dashboard.hero +
// dashboard.popup) can be fetched independently without clobbering each other.
const [advertisements, setAdvertisements] = useState({});
const [loading, setLoading] = useState({});
const [clickCounts, setClickCounts] = useState(loadClickCounts);
const [pendingClick, setPendingClick] = useState(null); // { ad, cta } awaiting confirmation
const [dismissConfirmOpen, setDismissConfirmOpen] = useState(false);
// ─── GET /api/client/advertisements/active?type=hero ──────────────────────
// Ad fetches must know the real preference before deciding visibility — never
// assume "show" as a default just because profile hasn't loaded yet. profileRef
// always holds the latest profile so even a stale fetch-function reference
// (captured by a page's mount-only effect) reads current data when it runs.
const profileRef = useRef(profile);
useEffect(() => { profileRef.current = profile; }, [profile]);
const profileFetchRef = useRef(null);
const ensureProfile = useCallback(async () => {
if (profileRef.current) return profileRef.current;
if (!profileFetchRef.current) {
profileFetchRef.current = getProfile().finally(() => { profileFetchRef.current = null; });
}
const fresh = await profileFetchRef.current;
profileRef.current = fresh;
return fresh;
}, [getProfile]);
// Popups are gated separately from hero/banner/sidebar so "Don't show this
// ad again" only ever touches popups, per the Settings → Advertisements toggles.
const resolveVisibility = (profileData, ad) => {
if (!ad) return ad;
const showPopupAds = profileData?.personal_info?.show_popup_ads ?? true;
const showOtherAds = profileData?.personal_info?.show_other_ads ?? true;
const hidden = ad.type === "popup" ? !showPopupAds : !showOtherAds;
return hidden ? null : ad;
};
// ─── GET /api/client/advertisements/active?placement=dashboard.hero ───────
const getActiveAdvertisement = useCallback(
async (type) => {
setLoading((prev) => ({ ...prev, [type]: true }));
async (placement) => {
setLoading((prev) => ({ ...prev, [placement]: true }));
try {
const { data } = await api.get("/client/advertisements/active", { params: { type } });
const ad = data?.data?.data ?? null;
setAdvertisements((prev) => ({ ...prev, [type]: ad }));
const [currentProfile, { data }] = await Promise.all([
ensureProfile(),
api.get("/client/advertisements/active", { params: { placement } }),
]);
const ad = resolveVisibility(currentProfile, data?.data?.data ?? null);
setAdvertisements((prev) => ({ ...prev, [placement]: ad }));
return ad;
} catch {
setAdvertisements((prev) => ({ ...prev, [type]: null }));
setAdvertisements((prev) => ({ ...prev, [placement]: null }));
return null;
} finally {
setLoading((prev) => ({ ...prev, [type]: false }));
setLoading((prev) => ({ ...prev, [placement]: false }));
}
},
[]
[ensureProfile]
);
// ─── GET /api/client/advertisements/active-batch?placements=a,b,c ─────────
// Resolves several placements in one round-trip — use for any page that
// needs more than one simultaneous slot.
const getActiveAdvertisements = useCallback(
async (placements) => {
if (!placements?.length) return {};
setLoading((prev) => {
const next = { ...prev };
placements.forEach((p) => { next[p] = true; });
return next;
});
try {
const [currentProfile, { data }] = await Promise.all([
ensureProfile(),
api.get("/client/advertisements/active-batch", {
params: { placements: placements.join(",") },
}),
]);
const raw = data?.data?.data ?? {};
const result = Object.fromEntries(
Object.entries(raw).map(([placement, ad]) => [placement, resolveVisibility(currentProfile, ad)])
);
setAdvertisements((prev) => ({ ...prev, ...result }));
return result;
} catch {
const fallback = Object.fromEntries(placements.map((p) => [p, null]));
setAdvertisements((prev) => ({ ...prev, ...fallback }));
return fallback;
} finally {
setLoading((prev) => {
const next = { ...prev };
placements.forEach((p) => { next[p] = false; });
return next;
});
}
},
[ensureProfile]
);
// ─── POST /api/client/advertisements/:advertisementId/click ───────────────
@@ -44,14 +138,105 @@ export function ClientAdvertisementsProvider({ children }) {
[]
);
// Tracks the click then follows the CTA link (external → new tab, internal → router nav).
const goToCta = useCallback(
(ad, cta) => {
trackClick(ad?.advertisement_id);
if (!cta?.link) return;
if (/^https?:\/\//.test(cta.link)) {
window.open(cta.link, "_blank", "noopener,noreferrer");
} else {
navigate(cta.link);
}
},
[navigate, trackClick]
);
// ─── CTA click guard ────────────────────────────────────────────────────
// Shared onCtaClick for every ad block (Banner/Hero/Sidebar/Popup). Counts
// clicks per advertisement for the browser session; once the limit is
// exceeded, hold the click behind a confirmation dialog instead of
// silently continuing.
const handleAdCtaClick = useCallback(
(ad, cta) => {
const id = ad?.advertisement_id;
if (!id) {
goToCta(ad, cta);
return;
}
const nextCount = (clickCounts[id] || 0) + 1;
setClickCounts((prev) => {
const next = { ...prev, [id]: nextCount };
sessionStorage.setItem(CLICK_STORAGE_KEY, JSON.stringify(next));
return next;
});
if (nextCount <= CLICK_LIMIT) {
goToCta(ad, cta);
} else {
setPendingClick({ ad, cta });
}
},
[clickCounts, goToCta]
);
const confirmPendingClick = () => {
if (pendingClick) goToCta(pendingClick.ad, pendingClick.cta);
setPendingClick(null);
};
// ─── "Don't show this ad again" (popups only) ──────────────────────────
// Persists the preference to the account (so it follows across devices),
// then shows a one-time confirmation pointing at where to turn it back on.
const dismissPopupForever = useCallback(async () => {
const result = await updateProfile({ show_popup_ads: false });
if (result?.data) profileRef.current = result.data;
setDismissConfirmOpen(true);
}, [updateProfile]);
const goToAdSettings = () => {
setDismissConfirmOpen(false);
navigate("/settings");
};
return (
<ClientAdvertisementsContext.Provider value={{
advertisements,
loading,
getActiveAdvertisement,
getActiveAdvertisements,
trackClick,
handleAdCtaClick,
dismissPopupForever,
}}>
{children}
<ResponsiveModal
open={!!pendingClick}
onOpenChange={(open) => { if (!open) setPendingClick(null); }}
title="Continue to this ad?"
description="You've clicked this advertisement several times already. Confirm you'd like to keep visiting it."
footer={
<>
<Button variant="outline" onClick={() => setPendingClick(null)}>Cancel</Button>
<Button onClick={confirmPendingClick}>Continue</Button>
</>
}
/>
<ResponsiveModal
open={dismissConfirmOpen}
onOpenChange={setDismissConfirmOpen}
title="Popup ads turned off"
description="You won't see popup ads anymore. You can turn them back on anytime in Settings → Advertisements."
footer={
<>
<Button variant="outline" onClick={() => setDismissConfirmOpen(false)}>Got it</Button>
<Button onClick={goToAdSettings}>Go to Settings</Button>
</>
}
/>
</ClientAdvertisementsContext.Provider>
);
}
}
+21 -3
View File
@@ -12,10 +12,13 @@ export function useClientNotifications() {
return ctx;
}
const DEFAULT_PAGINATION = { page: 1, limit: 10, pages: 1, total: 0 };
export function ClientNotificationProvider({ children }) {
const [notifications, setNotifications] = useState([]);
const [unseenCount, setUnseenCount] = useState(0);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
const intervalRef = useRef(null);
const pollSpeedRef = useRef(POLL_INTERVAL_NORMAL);
@@ -28,13 +31,14 @@ export function ClientNotificationProvider({ children }) {
}
}, []);
const fetchNotifications = useCallback(async () => {
const fetchNotifications = useCallback(async (page = 1, limit = 10) => {
setLoading(true);
try {
const res = await api.get("/client/notifications?limit=20");
const res = await api.get(`/client/notifications?limit=${limit}&page=${page}`);
const rows = res.data?.data?.notifications ?? [];
const pag = res.data?.data?.pagination ?? { page, limit, pages: 1, total: rows.length };
setNotifications(rows);
setUnseenCount(rows.filter(n => !n.seen).length);
setPagination({ ...pag, pages: Math.max(1, pag.pages) });
} catch {
// silent
} finally {
@@ -42,6 +46,18 @@ export function ClientNotificationProvider({ children }) {
}
}, []);
const clearAll = useCallback(async () => {
try {
await api.delete("/client/notifications/clear-all");
setNotifications([]);
setUnseenCount(0);
setPagination(DEFAULT_PAGINATION);
return true;
} catch {
return false;
}
}, []);
const markSeen = useCallback(async (id) => {
try {
await api.patch(`/client/notifications/${id}/seen`);
@@ -93,9 +109,11 @@ export function ClientNotificationProvider({ children }) {
notifications,
unseenCount,
loading,
pagination,
fetchNotifications,
markSeen,
markAllSeen,
clearAll,
accelerate,
decelerate,
}}>
+3 -6
View File
@@ -97,12 +97,10 @@ export function ClientTiersProvider({ children }) {
}, []);
// Returns { valid, code, type, value, discount, reason } from the server
const validatePromo = useCallback(async (plan_id, code, currency = null) => {
const validatePromo = useCallback(async (plan_id, code) => {
setPromoLoading(true);
try {
const payload = { plan_id, code };
if (currency) payload.currency = currency;
const { data } = await api.post('/client/tiers/promos/validate', payload);
const { data } = await api.post('/client/tiers/promos/validate', { plan_id, code });
return data.data ?? { valid: false, reason: 'No response from server.' };
} catch (err) {
return { valid: false, reason: err?.response?.data?.message ?? 'Invalid promo code.' };
@@ -112,12 +110,11 @@ export function ClientTiersProvider({ children }) {
}, []);
// Returns { payment_id, order_id, approval_url, amount, currency, ... } or null
const createOrder = useCallback(async (plan_id, promo_code = null, currency = null) => {
const createOrder = useCallback(async (plan_id, promo_code = null) => {
setCheckoutLoading(true);
try {
const payload = { plan_id };
if (promo_code) payload.promo_code = promo_code;
if (currency) payload.currency = currency;
const { data } = await api.post("/client/tiers/checkout/order", payload);
return data.data ?? null;
} catch (err) {
@@ -1,28 +0,0 @@
import { createContext, useContext, useState, useCallback } from 'react';
const STORAGE_KEY = 'currency-preference';
const CurrencyPreferenceContext = createContext(null);
export function CurrencyPreferenceProvider({ children }) {
const [currency, setCurrencyState] = useState(
() => localStorage.getItem(STORAGE_KEY) ?? 'USD'
);
const setCurrency = useCallback((code) => {
localStorage.setItem(STORAGE_KEY, code);
setCurrencyState(code);
}, []);
return (
<CurrencyPreferenceContext.Provider value={{ currency, setCurrency }}>
{children}
</CurrencyPreferenceContext.Provider>
);
}
export function useCurrencyPreference() {
const ctx = useContext(CurrencyPreferenceContext);
if (!ctx) throw new Error('useCurrencyPreference must be used inside CurrencyPreferenceProvider');
return ctx;
}
+4 -1
View File
@@ -30,9 +30,12 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
setProfileLoading(true);
try {
const { data } = await api.get(`${apiBase}/profile`);
setProfile(data.data ?? null);
const fresh = data.data ?? null;
setProfile(fresh);
return fresh;
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not load profile.");
return null;
} finally {
setProfileLoading(false);
}
+18 -15
View File
@@ -9,6 +9,7 @@ import { AdminTiersProvider } from "../AdminTiersContext";
import { AdminCategoriesProvider } from "../AdminCategoriesContext";
import { ProfileProvider } from "../ProfileProvider";
import { AdvertisementsProvider } from "../AdminAdvertisementContext";
import { NotificationBroadcastsProvider } from "../AdminNotificationBroadcastContext";
import { AdminNotificationProvider } from "../AdminNotificationContext"
import { AdminCourseReadingProgressProvider } from "../AdminCourseReadingProgressContext";
@@ -19,21 +20,23 @@ export const AdminProvider = ({ children }) => {
<ProfileProvider apiBase="/admin">
<AssetsProvider>
<AdvertisementsProvider>
<UserProvider>
<UserGroupProvider>
<AdminTiersProvider>
<AdminCategoriesProvider>
<CoursesProvider>
<AdminCourseReadingProgressProvider>
<AdminTaskProvider>
{children}
</AdminTaskProvider>
</AdminCourseReadingProgressProvider>
</CoursesProvider>
</AdminCategoriesProvider>
</AdminTiersProvider>
</UserGroupProvider>
</UserProvider>
<NotificationBroadcastsProvider>
<UserProvider>
<UserGroupProvider>
<AdminTiersProvider>
<AdminCategoriesProvider>
<CoursesProvider>
<AdminCourseReadingProgressProvider>
<AdminTaskProvider>
{children}
</AdminTaskProvider>
</AdminCourseReadingProgressProvider>
</CoursesProvider>
</AdminCategoriesProvider>
</AdminTiersProvider>
</UserGroupProvider>
</UserProvider>
</NotificationBroadcastsProvider>
</AdvertisementsProvider>
</AssetsProvider>
</ProfileProvider>
+4 -1
View File
@@ -1,4 +1,4 @@
import { Users, GitFork, FolderOpen, BookText, ListCheck, ShieldCheck, Megaphone } from "lucide-react";
import { Users, GitFork, FolderOpen, BookText, ListCheck, ShieldCheck, Megaphone, Bell, Trophy, Mail } from "lucide-react";
export const ADMIN_SECTIONS = [
{
@@ -39,6 +39,9 @@ export const ADMIN_SECTIONS = [
description: "Manage public-facing content",
tiles: [
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
{ key: "notifications", label: "Notifications", icon: Bell, link: "/admin/notifications" },
{ key: "achievements", label: "Achievements", icon: Trophy, link: "/admin/achievements" },
{ key: "email-templates", label: "Email Templates", icon: Mail, link: "/admin/email-templates" },
],
},
];
+24
View File
@@ -0,0 +1,24 @@
// data/cronPresets.data.js
// Mirrors backend data/cronPresets.data.js — the only schedule options the UI offers.
export const CRON_PRESET_OPTIONS = [
{ value: "every_minute", label: "Every minute" },
{ value: "every_5_min", label: "Every 5 minutes" },
{ value: "every_15_min", label: "Every 15 minutes" },
{ value: "hourly", label: "Hourly" },
{ value: "every_6_hours", label: "Every 6 hours" },
{ value: "daily", label: "Daily at midnight" },
];
export const CRON_PRESET_MAP = Object.fromEntries(
CRON_PRESET_OPTIONS.map((p) => [p.value, p])
);
// job_name -> friendly copy (label/description come from the API too, but this
// is used as a fallback and for the settings icon).
export const JOB_LABELS = {
taskOverdue: { label: "Task Overdue Alerts (Admin)", description: "Notifies admins when tasks flip to overdue." },
userNotifications: { label: "Task Overdue Alerts (Users)", description: "Notifies affected users when their tasks are marked overdue." },
issueCertificates: { label: "Certificate Issued", description: "Notifies users when a course certificate is ready." },
expireUserTiers: { label: "Tier Expired", description: "Notifies users when their subscription tier expires." },
};
+10
View File
@@ -0,0 +1,10 @@
export const EMAIL_BROADCAST_STATUSES = [
{ value: "queued", label: "Queued", badgeClass: "bg-muted text-muted-foreground border-border" },
{ value: "sending", label: "Sending", badgeClass: "bg-blue-100 text-blue-700 border-blue-400 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700" },
{ value: "completed", label: "Completed", badgeClass: "bg-emerald-100 text-emerald-700 border-emerald-400 dark:bg-emerald-900/40 dark:text-emerald-400 dark:border-emerald-700" },
{ value: "canceled", label: "Canceled", badgeClass: "bg-muted text-muted-foreground border-border" },
];
export const EMAIL_BROADCAST_STATUS_MAP = Object.fromEntries(
EMAIL_BROADCAST_STATUSES.map((s) => [s.value, s])
);
+46
View File
@@ -0,0 +1,46 @@
import { Megaphone, BadgePercent, Shield, Tag } from "lucide-react";
// Purely organizational — lets admins tell at a glance what an email template
// is for. Independent of `is_system` (which is about whether the type/row can
// be renamed or deleted, not what it's used for).
export const EMAIL_TEMPLATE_CATEGORIES = [
{
value: "announcement",
label: "Announcement",
description: "Platform news, updates, or broadcast-style messages to users.",
icon: Megaphone,
badgeClass: "bg-blue-100 text-blue-700 border-blue-400 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700",
},
{
value: "advertisement",
label: "Advertisement",
description: "Promotional or marketing content (offers, plans, campaigns).",
icon: BadgePercent,
badgeClass: "bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700",
},
{
value: "system",
label: "System",
description: "Account and transactional emails triggered by platform events.",
icon: Shield,
badgeClass: "bg-slate-100 text-slate-700 border-slate-400 dark:bg-slate-800/60 dark:text-slate-300 dark:border-slate-600",
},
{
value: "other",
label: "Other",
description: "Anything that doesn't fit the categories above.",
icon: Tag,
badgeClass: "bg-purple-100 text-purple-700 border-purple-400 dark:bg-purple-900/40 dark:text-purple-400 dark:border-purple-700",
},
];
export const getEmailTemplateCategory = (value) =>
EMAIL_TEMPLATE_CATEGORIES.find((c) => c.value === value) ?? EMAIL_TEMPLATE_CATEGORIES[3];
// Mirrors BROADCASTABLE_CATEGORIES in controllers/admin/email_broadcasts.controller.js —
// only these categories make sense to blast to real recipients. System/transactional
// templates are triggered per-user by app events, never mass-sent.
export const BROADCASTABLE_CATEGORIES = ["announcement", "advertisement"];
export const isBroadcastable = (template) =>
BROADCASTABLE_CATEGORIES.includes(template?.category) && template?.status === "sent";
@@ -0,0 +1,14 @@
// Reference-only registry of {{placeholder}} tokens available per system email
// type. Purely informational for the admin editor — the backend derives the
// real substitution data from wherever sendEmail({ type, data }) is called in
// code, this just tells the admin what's actually available to reference.
export const EMAIL_TEMPLATE_PLACEHOLDERS = {
OTP: ["otp", "expiryMinutes"],
WELCOME: ["name"],
PASSWORD_CHANGED: [],
ADDED_TO_GROUP: ["groupName"],
TASK_ASSIGNED: ["taskTitle", "dueDate"],
BAN_LIFTED: ["name", "email", "date"],
BANNED: ["name", "email", "date", "reason", "duration_word", "duration_label", "suspension_note"],
ADD_STAFF: ["name", "email", "password", "expiryHours"],
};
+11
View File
@@ -0,0 +1,11 @@
// A template is "sent" once it has live subject/html_body — that's the only
// content services/email.service.js's sendEmail() ever reads on the backend.
// Editing a sent template writes to draft_subject/draft_html_body instead, so
// "pending changes" means there's a draft sitting on top of the live version.
export const hasPendingChanges = (t) =>
t?.status === "sent" && (t?.draft_subject != null || t?.draft_html_body != null);
export const STATUS_META = {
draft: { label: "Draft", badgeClass: "bg-muted text-muted-foreground border-border" },
sent: { label: "Sent", badgeClass: "bg-emerald-100 text-emerald-700 border-emerald-400 dark:bg-emerald-900/40 dark:text-emerald-400 dark:border-emerald-700" },
};
+32
View File
@@ -0,0 +1,32 @@
// data/notificationBroadcast.data.js
import { Shield, Users, Megaphone, ListCheck, BookText, ShieldCheck } from "lucide-react";
// ─── Target types ─────────────────────────────────────────────────────────────
// Drives: target select in the Add form, target badge on each card.
// needsTarget: true means the form must also show a picker for target_id.
export const TARGET_TYPE_OPTIONS = [
{ value: "admin", label: "Admins", icon: Shield, description: "Visible only in the admin notification bell", needsTarget: false },
{ value: "user", label: "Users", icon: Users, description: "Sent to every active user", needsTarget: false },
{ value: "both", label: "Admins & Users", icon: Megaphone, description: "Sent to admins and every active user", needsTarget: false },
{ value: "task_list", label: "Task List", icon: ListCheck, description: "Sent to everyone assigned to a specific task list", needsTarget: true },
{ value: "course", label: "Course", icon: BookText, description: "Sent to everyone with access to a specific course", needsTarget: true },
{ value: "tier_plan", label: "Tier Plan", icon: ShieldCheck, description: "Sent to everyone currently on a specific tier plan", needsTarget: true },
];
export const TARGET_TYPE_MAP = Object.fromEntries(
TARGET_TYPE_OPTIONS.map((a) => [a.value, a])
);
// ─── Statuses ───────────────────────────────────────────────────────────────
// Drives: filter dropdown options, status badge color/label on each card.
export const BROADCAST_STATUSES = [
{ value: "draft", label: "Draft", badgeClass: "bg-muted text-muted-foreground" },
{ value: "sent", label: "Sent", badgeClass: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400" },
{ value: "archived", label: "Archived", badgeClass: "bg-muted text-muted-foreground" },
];
export const BROADCAST_STATUS_MAP = Object.fromEntries(
BROADCAST_STATUSES.map((s) => [s.value, s])
);
+27
View File
@@ -0,0 +1,27 @@
// data/placement.data.js
//
// Mirrors the backend placement registry (models/advertisements/advertisements.placements.js
// in new_starr). Each entry is a page + position slot; the format (hero/banner/popup/sidebar)
// is derived from the placement, never chosen independently. Keep this list in sync with the
// backend registry when adding a new placement — same pattern as ADVERTISEMENT_TYPES already
// mirroring the backend type ENUM.
export const PLACEMENTS = [
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
{ key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" },
{ key: "course_list.banner", format: "banner", page: "course_list", pageLabel: "Courses", slotLabel: "Banner (above course grid)" },
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
{ key: "course_details.sidebar", format: "sidebar", page: "course_details", pageLabel: "Course Details", slotLabel: "Sidebar (beside course content)" },
{ key: "plans.banner", format: "banner", page: "plans", pageLabel: "Plans", slotLabel: "Banner (above plan cards)" },
];
export const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p]));
// Grouped by page — powers the cascading Page → Position picker in Add/EditAdvertisement.
export const AD_PAGES = Object.values(
PLACEMENTS.reduce((acc, p) => {
if (!acc[p.page]) acc[p.page] = { page: p.page, pageLabel: p.pageLabel, placements: [] };
acc[p.page].placements.push(p);
return acc;
}, {})
);
+75
View File
@@ -0,0 +1,75 @@
// data/placementLayouts.data.js
//
// Declarative "wireframe recipe" per placement — powers <PlacementSkeleton />
// in the advertisement creation wizard so admins can see roughly where their
// ad will land before saving. Keys mirror PLACEMENTS in placement.data.js.
// Adding a new placement: add one entry here, nothing else needs to change.
//
// Block kinds:
// nav — thin top bar stand-in
// bar — a rounded block; `size` controls height (sm/md/lg/xl);
// `highlight: true` marks it as the ad slot, `label` names it
// filters — a row of small pill shapes
// grid — a row of small equal boxes (card grid stand-in)
// list — a couple of thin stacked rows (table/list stand-in)
// row — a flex row of `columns`, each rendered like a `bar`
//
// `overlay` (optional, top-level) renders the base `blocks` dimmed with a
// centered floating highlighted box on top — used for popups.
export const PLACEMENT_LAYOUTS = {
"dashboard.hero": {
blocks: [
{ kind: "nav" },
{ kind: "bar", size: "xl", highlight: true, label: "Hero" },
{ kind: "list", label: "My Groups" },
{ kind: "grid", label: "Courses" },
],
},
"dashboard.popup": {
blocks: [
{ kind: "nav" },
{ kind: "bar", size: "xl" },
{ kind: "list", label: "My Groups" },
{ kind: "grid", label: "Courses" },
],
overlay: { label: "Popup" },
},
"course_list.banner": {
blocks: [
{ kind: "nav" },
{ kind: "filters" },
{ kind: "bar", size: "md", highlight: true, label: "Banner" },
{ kind: "grid", label: "Course cards" },
],
},
"course_details.banner": {
blocks: [
{ kind: "nav" },
{ kind: "bar", size: "lg", label: "Course hero" },
{ kind: "bar", size: "sm", highlight: true, label: "Banner" },
{ kind: "row", columns: [
{ label: "Course content", width: "flex-1" },
{ label: "Sidebar", width: "w-1/4" },
] },
],
},
"course_details.sidebar": {
blocks: [
{ kind: "nav" },
{ kind: "bar", size: "lg", label: "Course hero" },
{ kind: "bar", size: "sm", label: "Banner" },
{ kind: "row", columns: [
{ label: "Course content", width: "flex-1" },
{ label: "Sidebar", width: "w-1/4", highlight: true },
] },
],
},
"plans.banner": {
blocks: [
{ kind: "nav" },
{ kind: "bar", size: "md", highlight: true, label: "Banner" },
{ kind: "grid", label: "Plan cards" },
],
},
};
-55
View File
@@ -1,55 +0,0 @@
import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
import { fmtCurrency } from '@/utils/datetime.util';
/**
* Returns bound currency formatters that automatically apply the user's
* preferred currency from CurrencyPreferenceContext.
*
* Usage:
* const { fmtPrice, currency, setCurrency } = useCurrency()
*
* // Format a plain amount in the user's preferred currency:
* fmtPrice(9.99)
*
* // Resolve + format a plan's localized price (plan.prices[] must be loaded):
* fmtPlanPrice(plan)
*/
export function useCurrency() {
const { currency, setCurrency } = useCurrencyPreference();
/** Format any amount in the user's preferred currency. */
function fmtPrice(amount) {
return fmtCurrency(amount, currency);
}
/**
* Resolve the correct price from a plan object and format it.
* plan.prices[] (localized overrides) takes priority over plan.price.
* Falls back to plan.price + plan.currency if no override exists.
*/
function fmtPlanPrice(plan) {
if (!plan) return '—';
const override = (plan.prices ?? []).find((p) => p.currency === currency);
if (override) return fmtCurrency(override.price, override.currency);
return fmtCurrency(plan.price, plan.currency);
}
/**
* Returns the effective { price, currency } for a plan without formatting.
* Useful when you need the raw numbers (e.g. sending to checkout).
*/
function resolvePlanPrice(plan) {
if (!plan) return { price: 0, currency: 'USD' };
const override = (plan.prices ?? []).find((p) => p.currency === currency);
if (override) return { price: Number(override.price), currency: override.currency };
return { price: Number(plan.price), currency: plan.currency };
}
return {
currency,
setCurrency,
fmtPrice,
fmtPlanPrice,
resolvePlanPrice,
};
}
+38 -30
View File
@@ -189,38 +189,38 @@
}
.dark {
--background: oklch(0.21 0.034 264.665);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--background: oklch(0.17 0.035 278);
--foreground: oklch(0.97 0.01 280);
--card: oklch(0.2 0.045 280);
--card-foreground: oklch(0.97 0.01 280);
--popover: oklch(0.2 0.045 280);
--popover-foreground: oklch(0.97 0.01 280);
--primary: oklch(0.63 0.25 302);
--primary-green: oklch(79.2% 0.209 151.711);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--primary-foreground: oklch(0.98 0.012 302);
--secondary: oklch(0.25 0.04 280);
--secondary-foreground: oklch(0.97 0.01 280);
--muted: oklch(0.22 0.035 280);
--muted-foreground: oklch(0.68 0.03 280);
--accent: oklch(0.28 0.06 285);
--accent-foreground: oklch(0.97 0.01 280);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--border: oklch(0.55 0.08 280 / 15%);
--input: oklch(0.55 0.08 280 / 20%);
--ring: oklch(0.63 0.25 302);
--chart-1: oklch(0.63 0.25 302);
--chart-2: oklch(0.68 0.28 330);
--chart-3: oklch(0.62 0.21 260);
--chart-4: oklch(0.7 0.18 200);
--chart-5: oklch(0.75 0.19 70);
--sidebar: oklch(0.13 0.03 280);
--sidebar-foreground: oklch(0.97 0.01 280);
--sidebar-primary: oklch(0.63 0.25 302);
--sidebar-primary-foreground: oklch(0.98 0.012 302);
--sidebar-accent: oklch(0.22 0.05 280);
--sidebar-accent-foreground: oklch(0.97 0.01 280);
--sidebar-border: oklch(0.55 0.08 280 / 12%);
--sidebar-ring: oklch(0.63 0.25 302);
--destructive-foreground: var(--color-red-400);
--info: var(--color-blue-500);
--info-foreground: var(--color-blue-400);
@@ -239,4 +239,12 @@
body {
@apply bg-background text-foreground;
}
}
/* Admin route: hardcoded violet accent for dark mode (temporary, reuses existing .dark violet tokens) */
.dark #philproperties-admin {
--primary: oklch(0.63 0.25 302);
--primary-foreground: oklch(0.98 0.012 302);
--ring: oklch(0.63 0.25 302);
--sidebar-ring: oklch(0.63 0.25 302);
}
@@ -185,7 +185,7 @@ function UserCard({ entry, onOpen }) {
<button
type="button"
onClick={() => onOpen(entry)}
className="w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="bg-background w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-accent/10 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />
@@ -325,7 +325,7 @@ export default function CourseReadingProgressList({ courseId }) {
}
return (
<>
<div className="space-y-4">
{/* ── Summary strip ── */}
<div className="flex items-center gap-4 flex-wrap text-sm">
<span className="flex items-center gap-1.5">
@@ -350,7 +350,7 @@ export default function CourseReadingProgressList({ courseId }) {
value={search}
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
maxLength={50}
className="pl-9 pr-16"
className="bg-background pl-9 pr-16"
/>
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs tabular-nums pointer-events-none ${search.length >= 50 ? 'text-destructive' : 'text-muted-foreground'}`}>
{search.length}/50
@@ -382,6 +382,6 @@ export default function CourseReadingProgressList({ courseId }) {
entry={dialogEntry}
courseId={courseId}
/>
</>
</div>
);
}
@@ -1,5 +1,6 @@
import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
@@ -119,6 +120,16 @@ export default function CoursesTable() {
onArchive={(c) => archiveCourse(c?.course_id)}
loading={loading}
onSuccess={handleArchiveSuccess}
onImpactCheck={async () => {
const { data } = await api.get(
`/admin/courses/${archiveTarget?.course_id}/archive-impact`
);
const { activeCount, totalCount } = data?.data ?? {};
return [
{ label: "student(s) are currently taking this course", count: activeCount ?? 0 },
{ label: "student(s) have progress in this course", count: totalCount ?? 0 },
];
}}
/>
{/* ── Bulk archive ── */}
@@ -1,5 +1,6 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
@@ -128,6 +129,16 @@ export default function UnitsTable({ courseId }) {
onArchive={(c) => archiveUnit(courseId, c?.unit_id)}
loading={loading}
onSuccess={handleArchiveSuccess}
onImpactCheck={async () => {
const { data } = await api.get(
`/admin/courses/${courseId}/units/${archiveTarget?.unit_id}/archive-impact`
);
const { completionCount, progressCount } = data?.data ?? {};
return [
{ label: "student(s) have completed this unit", count: completionCount ?? 0 },
{ label: "student(s) have reading progress in this unit", count: progressCount ?? 0 },
];
}}
/>
{/* ── Bulk archive ── */}
@@ -19,13 +19,23 @@ import { cn } from "@/lib/utils";
* isPreloaded — true in EditPlan (CoursePicker only mounts AFTER existing
* assignments are already in selectedIds, so no race condition).
* false in AddPlan (always bundle all on first load).
* currentPlanId — plan being edited (undefined in AddPlan). A course already
* assigned to a DIFFERENT plan is flagged as a conflict, since
* plan_courses.course_id is UNIQUE — a course belongs to at
* most one plan, and reassigning it here silently steals it
* away from that plan on save.
* onConflictsChange — (count: number) => void. Called whenever the number of
* currently-SELECTED courses that conflict with another plan
* changes, so the parent can block submission until resolved.
*
* Flow:
* • Shows "Bundle all?" question with two buttons.
* • "Yes, include all" → selects every course in the tier, hides picker.
* • "No, choose specific" → opens a Popover with Command+Search+Checkboxes.
* • Selected courses already owned by another plan are called out, and the
* parent is expected to disable submission until they're unchecked.
*/
export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded = false }) {
export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded = false, currentPlanId, onConflictsChange }) {
const [courses, setCourses] = useState([]);
const [loading, setLoading] = useState(false);
const [bundleAll, setBundleAll] = useState(true);
@@ -75,6 +85,29 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
);
}, [courses, search]);
// Courses already owned by a DIFFERENT plan — selecting them here will move them.
const isConflict = (course) =>
course.assigned_plan && String(course.assigned_plan.plan_id) !== String(currentPlanId ?? "");
// Only courses actually SELECTED matter — unchecking a conflicting course clears it.
const conflicts = useMemo(
() => courses.filter((c) => isConflict(c) && selectedIds.has(String(c.course_id))),
[courses, currentPlanId, selectedIds] // eslint-disable-line react-hooks/exhaustive-deps
);
const conflictsByPlan = useMemo(() => {
const map = new Map();
conflicts.forEach((c) => {
const label = c.assigned_plan.label;
map.set(label, (map.get(label) ?? 0) + 1);
});
return [...map.entries()];
}, [conflicts]);
useEffect(() => {
onConflictsChange?.(conflicts.length);
}, [conflicts.length]); // eslint-disable-line react-hooks/exhaustive-deps
const toggle = (id) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
@@ -147,6 +180,26 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
</p>
)}
{/* ── Already-assigned-elsewhere warning ──────────────────────── */}
{!loading && conflicts.length > 0 && (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-3 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">
{conflicts.length} course{conflicts.length !== 1 ? "s" : ""} already belong{conflicts.length === 1 ? "s" : ""} to another plan.
</p>
<p>
A course can only belong to one plan at a time. Assigning {conflicts.length === 1 ? "it" : "them"} here will move {conflicts.length === 1 ? "it" : "them"} out of{" "}
{conflictsByPlan.map(([label, count], i) => (
<span key={label}>
<span className="font-medium">{label}</span> ({count}){i < conflictsByPlan.length - 1 ? ", " : ""}
</span>
))}. Uncheck them below if that's not what you want.
</p>
</div>
</div>
)}
{/* ── No courses in tier ───────────────────────────────────────── */}
{!loading && total === 0 && (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
@@ -190,8 +243,9 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
) : (
<ScrollArea className="h-64">
{filtered.map((course) => {
const id = String(course.course_id);
const checked = selectedIds.has(id);
const id = String(course.course_id);
const checked = selectedIds.has(id);
const conflict = isConflict(course);
return (
<CommandItem
key={id}
@@ -212,6 +266,12 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
{course.description}
</span>
)}
{conflict && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded px-1.5 py-0.5 w-fit mt-0.5">
<AlertTriangle className="size-3" />
In "{course.assigned_plan.label}"
</span>
)}
</div>
</CommandItem>
);
@@ -0,0 +1,61 @@
import { useState } from "react";
import { ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command, CommandEmpty, CommandGroup,
CommandInput, CommandItem, CommandList,
} from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
export function CurrencyPicker({ value, currencies, onValueChange }) {
const [open, setOpen] = useState(false);
const selected = currencies.find((c) => c.code === value);
const label = selected ? `${selected.code} — ${selected.name}` : "Select currency";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
<span className="truncate">{label}</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[--radix-popover-trigger-width] p-0"
>
<Command>
<CommandInput placeholder="Search currency…" />
<ScrollArea className="h-64">
<CommandList className="max-h-none">
<CommandEmpty>No currency found.</CommandEmpty>
<CommandGroup>
{currencies.map((c) => (
<CommandItem
key={c.code}
value={`${c.code} ${c.name}`}
data-checked={value === c.code}
onSelect={() => {
onValueChange(c.code);
setOpen(false);
}}
>
{c.code} — {c.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -1,4 +1,4 @@
import { Eye, Pencil, Archive, ShelvingUnit, NotebookPen, ClipboardList } from "lucide-react";
import { Eye, Archive, ShelvingUnit, NotebookPen, ClipboardList, PlusCircle } from "lucide-react";
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment }) {
return [
@@ -8,19 +8,21 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
{
key: "edit",
label: "Edit Info",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => onEdit(row),
},
{
key: "view_units",
label: "View Units",
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onViewUnits(row),
separator: true
separator: true,
},
{
key: "create_assessment",
label: "Create Assessment",
icon: <PlusCircle className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onAssessment(row),
hidden: (row) => !!row.assessment_id,
},
{
key: "view_assessment",
@@ -28,7 +30,7 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
icon: <ClipboardList className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onViewAssessment(row),
separator: true,
hidden: (row) => !row.assessment_id,
},
{
key: "modify_assessment",
@@ -36,6 +38,7 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
icon: <NotebookPen className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onAssessment(row),
hidden: (row) => !row.assessment_id,
},
{
key: "archive",
@@ -43,7 +46,7 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive",
onClick: (row) => onArchive(row),
separator: true
separator: true,
},
]
];
}
@@ -1,4 +1,4 @@
import { Eye, Pencil, Archive, BookCheck, NotebookPen, ClipboardList } from "lucide-react";
import { Eye, Pencil, Archive, BookCheck, NotebookPen, ClipboardList, PlusCircle } from "lucide-react";
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz, onViewQuiz }) {
return [
@@ -20,7 +20,16 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
icon: <BookCheck className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onViewLessons(row),
separator: true
separator: true,
},
{
key: "create_quiz",
label: "Create Quiz",
icon: <PlusCircle className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
hidden: (row) => !!(row.quiz_id || row.quiz),
separator: true,
},
{
key: "view_quiz",
@@ -28,6 +37,7 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
icon: <ClipboardList className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onViewQuiz(row),
hidden: (row) => !(row.quiz_id || row.quiz),
separator: true,
},
{
@@ -36,6 +46,7 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
icon: <NotebookPen className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
hidden: (row) => !(row.quiz_id || row.quiz),
},
{
key: "archive",
@@ -43,7 +54,7 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive",
onClick: (row) => onArchive(row),
separator: true
separator: true,
},
]
];
}
@@ -20,10 +20,11 @@ const STATUS_BADGE = {
export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v ? new Date(v).toLocaleString() : "—", tierMap = {}) {
const cellOverrides = {
status: (info) => (
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
{info.getValue()}
</Badge>
user_full_name: (info) => (
<span className="text-sm font-medium">{info.getValue() ?? "—"}</span>
),
"user.email": (info) => (
<span className="text-sm">{info.getValue() ?? "—"}</span>
),
amount: (info) => {
const row = info.row.original;
@@ -33,6 +34,11 @@ export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v
</span>
);
},
status: (info) => (
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
{info.getValue()}
</Badge>
),
"plan.tier": (info) => {
const { cls, label } = resolveTierBadge(info.getValue(), tierMap);
return <Badge className={`${cls} capitalize`}>{label}</Badge>;
@@ -42,9 +48,6 @@ export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v
{fmtDateTime(info.getValue())}
</span>
),
"user.email": (info) => (
<span className="text-sm">{info.getValue() ?? "—"}</span>
),
};
const visibleAttributes = attributes.filter((a) => !a.hidden);
@@ -1,4 +1,4 @@
import { Eye, Pencil, Archive, ShelvingUnit, CreditCard, Globe } from "lucide-react";
import { Eye, Archive } from "lucide-react";
export function buildRowActions({ navigate, onArchive }) {
return [
@@ -8,28 +8,6 @@ export function buildRowActions({ navigate, onArchive }) {
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/view`),
},
{
key: "edit",
label: "Edit Plan",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/edit`),
},
{
key: "payment_policy",
label: "Payment Policy",
icon: <CreditCard className="h-3.5 w-3.5" />,
className: "text-blue-700 hover:text-blue-600",
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/payment-policy`),
separator: true,
},
{
key: "view_payments",
label: "View Payments",
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => navigate(`/admin/tiers/payments?plan_id=${row.plan_id}`),
},
{
key: "archive",
label: "Archive",
@@ -1,4 +1,4 @@
import { Plus, RefreshCw, Download, Archive, Layers, Globe } from "lucide-react";
import { Plus, RefreshCw, Download, Archive, Layers } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
@@ -44,14 +44,6 @@ export function buildToolbarActions({
variant: "outline",
onClick: () => navigate("/admin/tiers/categories"),
},
{
key: "localized-prices",
type: "button",
label: "Localized Prices",
icon: <Globe className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => navigate("/admin/tiers/prices"),
},
{
key: "create",
type: "button",
+2 -2
View File
@@ -84,14 +84,14 @@ const AdminLayout = () => {
</div>
</div>
<div id="main-body" className="bg-slate-100 flex-1 flex flex-col" style={{ paddingTop: 'var(--navbar-h)' }}>
<div id="main-body" className="bg-background flex-1 flex flex-col" style={{ paddingTop: 'var(--navbar-h)' }}>
<Outlet />
<Toaster position="bottom-right" richColors />
</div>
</AdminProvider>
{/* Footer sits outside AdminProvider intentionally */}
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground bg-background">
© Philproperties, 2026
</footer>
</TooltipProvider>
@@ -0,0 +1,172 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as LucideIcons from "lucide-react";
import { House, Plus, Pencil, Trash2, Trophy, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminAchievementsProvider,
useAdminAchievements,
} from "@/contexts/AdminAchievementsContext";
function AchievementCard({ item, onEdit, onDelete }) {
const Icon = LucideIcons[item.icon] ?? Trophy;
return (
<div className="rounded-lg border bg-card p-5 flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg border bg-muted flex items-center justify-center shrink-0">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold">{item.label}</p>
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{item.key}</code>
<Badge variant="outline" className="text-[10px] capitalize">{item.type}</Badge>
{!item.is_active && <Badge variant="secondary">Inactive</Badge>}
{item.is_system && (
<Badge variant="secondary" className="gap-1">
<Lock className="h-2.5 w-2.5" /> System
</Badge>
)}
</div>
{item.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.description}</p>
)}
{item.trigger && (
<p className="text-xs text-muted-foreground mt-0.5">
Trigger: <span className="font-medium text-foreground capitalize">{item.trigger}</span>
</p>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
{!item.is_system && (
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
);
}
function AchievementsInner() {
const navigate = useNavigate();
const { achievements, loading, fetchAchievements, deleteAchievement } = useAdminAchievements();
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
useEffect(() => { fetchAchievements(); }, []);
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
await deleteAchievement(deleteTarget.achievement_definition_id);
setDeleting(false);
setDeleteTarget(null);
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Achievements - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Achievements" },
]} />
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold">Achievements</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Badges and milestones learners can earn across the platform.
</p>
</div>
<Button size="sm" onClick={() => navigate("/admin/achievements/add")}>
<Plus className="h-4 w-4 mr-2" />
Add Achievement
</Button>
</div>
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
<strong>System</strong> achievements are auto-granted by platform events (registration, course completion, etc.)
and cannot be deleted or have their key/type changed — everything else stays editable.
</p>
</div>
<Separator className="mb-5" />
{loading && !achievements.length ? (
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
) : !achievements.length ? (
<p className="text-sm text-muted-foreground text-center py-12">No achievements found.</p>
) : (
<div className="space-y-3">
{achievements.map((item) => (
<AchievementCard
key={item.achievement_definition_id}
item={item}
onEdit={(a) => navigate(`/admin/achievements/${a.achievement_definition_id}/edit`)}
onDelete={(a) => setDeleteTarget(a)}
/>
))}
</div>
)}
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Achievement</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{deleteTarget?.label}</span>?
This action cannot be undone. Any courses referencing this achievement must be unassigned first.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
{deleting && <Spinner className="h-4 w-4 mr-2" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
export default function Achievements() {
return (
<AdminAchievementsProvider>
<AchievementsInner />
</AdminAchievementsProvider>
);
}
@@ -0,0 +1,255 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, X, Lock } from "lucide-react";
import { BADGE_ICON_OPTIONS } from "@/modules/admin/pages/tiers/EditTierCategory";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminAchievementsProvider,
useAdminAchievements,
} from "@/contexts/AdminAchievementsContext";
const TRIGGER_OPTIONS = [
{ value: "auth", label: "Auth (registration / login)" },
{ value: "tier", label: "Tier (subscription purchase)" },
{ value: "course", label: "Course (lessons / quizzes)" },
{ value: "profile", label: "Profile completion" },
{ value: "social", label: "Social (referrals / community)" },
{ value: "manual", label: "Manual (admin-granted only)" },
];
function SectionCard({ title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
{children}
</div>
);
}
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function EditAchievementInner({ isAdd }) {
const navigate = useNavigate();
const { id } = useParams();
const { achievement, loading, fetchAchievement, createAchievement, updateAchievement } = useAdminAchievements();
const [key, setKey] = useState("");
const [type, setType] = useState("badge");
const [label, setLabel] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState(null);
const [trigger, setTrigger] = useState("manual");
const [isActive, setIsActive] = useState(true);
const [errors, setErrors] = useState({});
useEffect(() => {
if (!isAdd && id) fetchAchievement(id);
}, [id, isAdd]);
useEffect(() => {
if (achievement && !isAdd) {
setKey(achievement.key ?? "");
setType(achievement.type ?? "badge");
setLabel(achievement.label ?? "");
setDescription(achievement.description ?? "");
setIcon(achievement.icon ?? null);
setTrigger(achievement.trigger ?? "manual");
setIsActive(achievement.is_active ?? true);
}
}, [achievement, isAdd]);
const validate = () => {
const e = {};
if (!label.trim()) e.label = "Label is required.";
if (isAdd && !key.trim()) e.key = "Key is required.";
if (isAdd && !/^[a-z0-9_]+$/.test(key)) e.key = "Key must be lowercase letters, numbers or underscores.";
setErrors(e);
return !Object.keys(e).length;
};
const handleSave = async () => {
if (!validate()) return;
const payload = {
type,
label: label.trim(),
description: description.trim() || null,
icon: icon || null,
trigger: trigger || null,
is_active: isActive,
};
if (isAdd) {
payload.key = key.trim();
const result = await createAchievement(payload);
if (result) navigate("/admin/achievements");
} else {
const result = await updateAchievement(id, payload);
if (result) navigate("/admin/achievements");
}
};
const isSystem = !isAdd && achievement?.is_system;
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={isAdd ? "Add Achievement - STARR" : "Edit Achievement - STARR"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Achievements", to: "/admin/achievements" },
{ label: isAdd ? "Add Achievement" : (achievement?.label ?? "Edit") },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">{isAdd ? "Add Achievement" : "Edit Achievement"}</h1>
<p className="text-sm text-muted-foreground">
{isAdd ? "Define a new badge or milestone learners can earn." : "Update this achievement's details."}
</p>
</div>
</div>
{isSystem && (
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
This is a <strong>system</strong> achievement — it's auto-granted by platform code that references
its key directly, so the key and type are locked. Label, description, icon, trigger and active state are still editable.
</p>
</div>
)}
<div className="space-y-5">
<SectionCard title="Achievement Details">
<div className="space-y-1.5">
<Label htmlFor="key">Key <span className="text-destructive">*</span></Label>
<Input
id="key"
value={key}
onChange={(e) => setKey(e.target.value.toLowerCase())}
placeholder="e.g. course_marathon"
disabled={!isAdd}
/>
<p className="text-xs text-muted-foreground">Lowercase, no spaces. Cannot be changed after creation.</p>
<FieldError message={errors.key} />
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Course Marathon" />
<FieldError message={errors.label} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="What does a learner do to earn this?" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType} disabled={isSystem}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="badge">Badge</SelectItem>
<SelectItem value="milestone">Milestone</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Trigger</Label>
<Select value={trigger} onValueChange={setTrigger}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Informational only — doesn't wire up new automatic grants by itself.</p>
</div>
</div>
<div className="flex items-center gap-3">
<Switch id="is_active" checked={isActive} onCheckedChange={setIsActive} />
<Label htmlFor="is_active">Active</Label>
</div>
</SectionCard>
<SectionCard title="Icon">
<p className="text-xs text-muted-foreground -mt-1">
Shown next to this achievement wherever it's displayed to learners.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setIcon(null)}
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 text-xs text-muted-foreground transition-all ${!icon ? "border-foreground bg-muted scale-105" : "border-border hover:border-muted-foreground"}`}
title="No icon"
>
<X className="size-3.5" />
</button>
{BADGE_ICON_OPTIONS.map(({ name, icon: Icon }) => {
const selected = icon === name;
return (
<button
key={name}
type="button"
title={name}
onClick={() => setIcon(name)}
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 transition-all ${selected ? "bg-secondary text-secondary-foreground border-foreground scale-105" : "border-border hover:border-muted-foreground"}`}
>
<Icon className="size-4" />
</button>
);
})}
</div>
{icon && (
<p className="text-xs text-muted-foreground">Selected: <span className="font-medium">{icon}</span></p>
)}
</SectionCard>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" onClick={handleSave} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
{isAdd ? "Create Achievement" : "Save Changes"}
</Button>
</div>
</div>
</div>
</div>
</section>
);
}
function Wrapper({ isAdd }) {
return (
<AdminAchievementsProvider>
<EditAchievementInner isAdd={isAdd} />
</AdminAchievementsProvider>
);
}
export function AddAchievement() { return <Wrapper isAdd={true} />; }
export function EditAchievement() { return <Wrapper isAdd={false} />; }
@@ -1,14 +1,19 @@
// modules/admin/pages/advertisements/AddAdvertisement.jsx
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { House, Plus, Trash2, ImagePlus } from "lucide-react";
import {
House, Plus, Trash2, ImagePlus, MapPin, FileText,
Link2, CalendarClock, Check, ChevronLeft, ChevronRight,
} from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
import { resolveAssetSrc } from "@/utils/media.util";
import { cn } from "@/lib/utils";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -16,15 +21,19 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { PlacementSkeleton } from "@/components/generic/PlacementSkeleton";
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
placement: z.string().min(1, "Placement is required."),
badge_label: z.string().optional(),
headline: z.string().optional(),
description: z.string().optional(),
@@ -39,9 +48,10 @@ const schema = z.object({
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
size: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
}).superRefine((data, ctx) => {
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
const format = PLACEMENT_MAP[data.placement]?.format;
if (format === "hero" && (data.description?.length ?? 0) > 200) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 200,
@@ -53,6 +63,18 @@ const schema = z.object({
}
});
// ─── Steps ────────────────────────────────────────────────────────────────────
// richOnly steps are skipped entirely for placements whose format isn't a
// RICH_CONTENT_TYPES format (banner/popup/sidebar never had CTAs).
const ALL_STEPS = [
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose the page and position this ad will appear in." },
{ id: "content", label: "Content", icon: FileText, description: "Headline, description, and badge text for this placement." },
{ id: "image", label: "Image", icon: ImagePlus, description: "Choose an existing asset from Asset Management." },
{ id: "ctas", label: "CTAs", icon: Link2, description: `Up to ${MAX_CTAS} buttons shown on the placement.`, richOnly: true },
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Optional start/end dates, manual ordering, and the on/off switch." },
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this advertisement." },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
@@ -60,24 +82,11 @@ function FieldError({ message }) {
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function SectionCard({ title, description, children }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-5">
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
{ value: "xl", label: "Extra Large" },
];
const CTA_VARIANTS = [
@@ -85,6 +94,348 @@ const CTA_VARIANTS = [
{ value: "outline", label: "Outline" },
];
// ─── Stepper header ─────────────────────────────────────────────────────────
function Stepper({ steps, stepIndex }) {
return (
<div className="flex items-center gap-0">
{steps.map((s, i) => {
const Icon = s.icon;
const isActive = stepIndex === i;
const isDone = stepIndex > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors shrink-0",
isDone && "bg-emerald-600 border-emerald-600 text-white",
isActive && "border-primary bg-primary text-primary-foreground",
!isActive && !isDone && "border-border bg-background text-muted-foreground"
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
isActive ? "text-foreground" : "text-muted-foreground",
isDone ? "text-emerald-600" : ""
)}>
{s.label}
</span>
</div>
{i < steps.length - 1 && (
<div className={cn(
"flex-1 h-px mx-2 mb-4 transition-colors",
stepIndex > i ? "bg-emerald-600" : "bg-border"
)} />
)}
</div>
);
})}
</div>
);
}
// ─── Step: Placement ────────────────────────────────────────────────────────
function StepPlacement({ selectedPage, setSelectedPage, placement, setValue, positionOptions, errors, format, isBanner, watch }) {
return (
<div className="space-y-5">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Page</Label>
<Select
value={selectedPage ?? undefined}
onValueChange={(v) => {
setSelectedPage(v);
setValue("placement", "", { shouldValidate: false });
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a page" />
</SelectTrigger>
<SelectContent>
{AD_PAGES.map((p) => (
<SelectItem key={p.page} value={p.page}>{p.pageLabel}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block">Position</Label>
<Select
value={placement || undefined}
onValueChange={(v) => setValue("placement", v, { shouldValidate: true })}
disabled={!selectedPage}
>
<SelectTrigger>
<SelectValue placeholder={selectedPage ? "Select a position" : "Select a page first"} />
</SelectTrigger>
<SelectContent>
{positionOptions.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.slotLabel}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.placement?.message} />
</div>
</div>
{placement && (
<div>
<p className="text-xs text-muted-foreground mb-2">
Preview — the highlighted area is roughly where this ad will appear.
</p>
<PlacementSkeleton placement={placement} />
<p className="text-xs text-muted-foreground mt-2">
Format: <span className="font-medium text-foreground capitalize">{format}</span> — determined by the placement above.
</p>
</div>
)}
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
<SelectTrigger>
<SelectValue placeholder="Select a size" />
</SelectTrigger>
<SelectContent>
{BANNER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">
Controls the banner's height. Width always stretches full-width.
</p>
</div>
)}
</div>
);
}
// ─── Step: Content ──────────────────────────────────────────────────────────
function StepContent({ register, errors, showRichContent, description, format }) {
if (!showRichContent) {
return (
<div>
<Label className="mb-1.5 block">Headline (optional)</Label>
<Input placeholder="Internal label for this ad" {...register("headline")} />
</div>
);
}
return (
<div className="space-y-5">
<div>
<Label className="mb-1.5 block">Badge label</Label>
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
</div>
<div>
<Label className="mb-1.5 block">Headline</Label>
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
</div>
<div>
<Label className="mb-1.5 block">Description</Label>
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
{format === "hero" && (
<div className="flex justify-between items-start mt-1">
<FieldError message={errors.description?.message} />
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
{description?.length ?? 0}/200
</span>
</div>
)}
</div>
</div>
);
}
// ─── Step: Image ────────────────────────────────────────────────────────────
function StepImage({ selectedAsset, imageUrl, setPickerOpen }) {
return selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
onClick={() => setPickerOpen(true)}
>
<img
src={imageUrl || resolveAssetSrc(selectedAsset)}
alt={selectedAsset.display_name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
>
<ImagePlus className="size-5" />
<span className="text-sm">Select an image</span>
</button>
);
}
// ─── Step: CTAs ─────────────────────────────────────────────────────────────
function StepCtas({ ctaFields, register, errors, watch, setValue, appendCta, removeCta }) {
return (
<div className="space-y-3">
{ctaFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
<FieldError message={errors.ctas?.[index]?.label?.message} />
</div>
<div className="flex-1">
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
<FieldError message={errors.ctas?.[index]?.link?.message} />
</div>
<div className="w-[120px]">
<Select
value={watch(`ctas.${index}.variant`) ?? "default"}
onValueChange={(v) => setValue(`ctas.${index}.variant`, v)}
>
<SelectTrigger>
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{CTA_VARIANTS.map((v) => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
{ctaFields.length < MAX_CTAS ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
>
<Plus className="size-3.5" />
Add CTA
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
)}
</div>
);
}
// ─── Step: Scheduling & Display ─────────────────────────────────────────────
function StepScheduling({ register, watch, setValue }) {
return (
<div className="space-y-5">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Start date</Label>
<Input type="datetime-local" {...register("start_date")} />
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<Input type="datetime-local" {...register("end_date")} />
</div>
</div>
<Separator />
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Order</Label>
<Input type="number" min={0} {...register("order")} />
</div>
<div className="flex items-center justify-between border rounded-md px-3 h-9">
<Label className="text-sm">Active</Label>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v)}
/>
</div>
</div>
</div>
);
}
// ─── Step: Review ───────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm gap-4">
<span className="text-muted-foreground min-w-[110px] shrink-0">{label}</span>
<span className="text-foreground text-right break-words">{value}</span>
</div>
);
}
function StepReview({ data, selectedAsset, imageUrl }) {
const placementMeta = PLACEMENT_MAP[data.placement];
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
return (
<div className="space-y-4">
<div className="border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Placement</span>
{placementMeta && <Badge variant="secondary" className="ml-auto capitalize">{placementMeta.format}</Badge>}
</div>
<SummaryRow label="Page" value={placementMeta?.pageLabel} />
<SummaryRow label="Position" value={placementMeta?.slotLabel} />
<SummaryRow label="Size" value={data.size} />
</div>
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Content</p>
<SummaryRow label="Badge" value={data.badge_label} />
<SummaryRow label="Headline" value={data.headline} />
<SummaryRow label="Description" value={data.description} />
</div>
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Image</p>
{selectedAsset ? (
<img
src={imageUrl || resolveAssetSrc(selectedAsset)}
alt={selectedAsset.display_name}
className="h-24 rounded border object-cover"
/>
) : (
<p className="text-sm text-muted-foreground">No image selected.</p>
)}
</div>
{ctas.length > 0 && (
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Calls to action</p>
{ctas.map((c, i) => (
<SummaryRow key={i} label={c.label || "—"} value={c.link} />
))}
</div>
)}
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Scheduling & display</p>
<SummaryRow label="Start date" value={data.start_date} />
<SummaryRow label="End date" value={data.end_date} />
<SummaryRow label="Order" value={data.order} />
<SummaryRow label="Active" value={data.is_active ? "Yes" : "No"} />
</div>
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function AddAdvertisement() {
@@ -94,18 +445,23 @@ export default function AddAdvertisement() {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
const [selectedPage, setSelectedPage] = useState(null);
const [step, setStep] = useState(0);
const {
register,
handleSubmit,
control,
trigger,
getValues,
watch,
setValue,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
type: undefined,
placement: undefined,
badge_label: "",
headline: "",
description: "",
@@ -121,10 +477,19 @@ export default function AddAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const type = watch("type");
const placement = watch("placement");
const description = watch("description");
const showRichContent = RICH_CONTENT_TYPES.includes(type);
const isBanner = type === "banner";
const format = PLACEMENT_MAP[placement]?.format;
const showRichContent = RICH_CONTENT_TYPES.includes(format);
const isBanner = format === "banner";
const positionOptions = AD_PAGES.find((p) => p.page === selectedPage)?.placements ?? [];
const steps = useMemo(
() => ALL_STEPS.filter((s) => !s.richOnly || showRichContent),
[showRichContent]
);
const stepIndex = Math.min(step, steps.length - 1);
const current = steps[stepIndex];
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -132,19 +497,33 @@ export default function AddAdvertisement() {
{ label: "New" },
];
const onSubmit = async (values) => {
// Validate only the current step's fields before advancing
const handleNext = async () => {
let fields = [];
if (current.id === "placement") fields = ["placement"];
else if (current.id === "content") fields = showRichContent ? ["headline", "description", "badge_label"] : ["headline"];
else if (current.id === "ctas") fields = ["ctas"];
const valid = fields.length ? await trigger(fields) : true;
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
};
const handleBack = () => setStep((s) => Math.max(s - 1, 0));
// Called manually — no <form> tag so Enter/click on earlier steps can't accidentally submit
const handleCreate = handleSubmit(async (values) => {
const payload = {
...values,
image_asset_id: values.image_asset_id || null,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: values.type === "banner" ? (values.size || "md") : null,
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
createdBy: user?.user_id ?? null,
};
const res = await createAdvertisement(payload);
if (res) navigate("/admin/advertisements");
};
});
return (
<section className="bg-muted h-full">
@@ -153,204 +532,87 @@ export default function AddAdvertisement() {
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
<p className="text-sm text-muted-foreground mb-6">Create a banner, popup, or hero placement.</p>
<div className="w-full max-w-2xl pb-10 space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
<p className="text-sm text-muted-foreground">Create a banner, popup, or hero placement.</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<Stepper steps={steps} stepIndex={stepIndex} />
<SectionCard title="Placement" description="Where this advertisement will appear.">
<div>
<Label className="mb-1.5 block">Type</Label>
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true })}>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{ADVERTISEMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.type?.message} />
{type && (
<p className="text-xs text-muted-foreground mt-1.5">
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
</p>
)}
</div>
<div className="rounded-lg border bg-card p-6 min-h-[360px]">
<div className="space-y-0.5 pb-4 mb-1 border-b">
<h2 className="text-sm font-semibold">{current.label}</h2>
<p className="text-xs text-muted-foreground">{current.description}</p>
</div>
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
<SelectTrigger>
<SelectValue placeholder="Select a size" />
</SelectTrigger>
<SelectContent>
{BANNER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">
Controls the banner's height. Width always stretches full-width.
</p>
</div>
)}
</SectionCard>
{showRichContent && (
<SectionCard title="Content" description="Headline, description, and badge text shown on the hero placement.">
<div>
<Label className="mb-1.5 block">Badge label</Label>
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
</div>
<div>
<Label className="mb-1.5 block">Headline</Label>
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
</div>
<div>
<Label className="mb-1.5 block">Description</Label>
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
{type === "hero" && (
<div className="flex justify-between items-start mt-1">
<FieldError message={errors.description?.message} />
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
{description?.length ?? 0}/200
</span>
</div>
)}
</div>
</SectionCard>
{current.id === "placement" && (
<StepPlacement
selectedPage={selectedPage}
setSelectedPage={setSelectedPage}
placement={placement}
setValue={setValue}
positionOptions={positionOptions}
errors={errors}
format={format}
isBanner={isBanner}
watch={watch}
/>
)}
{!showRichContent && (
<SectionCard title="Content" description="Optional headline for this placement.">
<div>
<Label className="mb-1.5 block">Headline (optional)</Label>
<Input placeholder="Internal label for this ad" {...register("headline")} />
</div>
</SectionCard>
{current.id === "content" && (
<StepContent
register={register}
errors={errors}
showRichContent={showRichContent}
description={description}
format={format}
/>
)}
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
{selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
onClick={() => setPickerOpen(true)}
>
<img
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
alt={selectedAsset.display_name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
>
<ImagePlus className="size-5" />
<span className="text-sm">Select an image</span>
</button>
)}
</SectionCard>
{showRichContent && (
<SectionCard
title="Calls to action"
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
>
{ctaFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
<FieldError message={errors.ctas?.[index]?.label?.message} />
</div>
<div className="flex-1">
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
<FieldError message={errors.ctas?.[index]?.link?.message} />
</div>
<div className="w-[120px]">
<Select
value={watch(`ctas.${index}.variant`) ?? "default"}
onValueChange={(v) => setValue(`ctas.${index}.variant`, v)}
>
<SelectTrigger>
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{CTA_VARIANTS.map((v) => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
{ctaFields.length < MAX_CTAS ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
>
<Plus className="size-3.5" />
Add CTA
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
)}
</SectionCard>
{current.id === "image" && (
<StepImage selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} setPickerOpen={setPickerOpen} />
)}
{current.id === "ctas" && (
<StepCtas
ctaFields={ctaFields}
register={register}
errors={errors}
watch={watch}
setValue={setValue}
appendCta={appendCta}
removeCta={removeCta}
/>
)}
{current.id === "scheduling" && (
<StepScheduling register={register} watch={watch} setValue={setValue} />
)}
{current.id === "review" && (
<StepReview data={getValues()} selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} />
)}
</div>
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Start date</Label>
<Input type="datetime-local" {...register("start_date")} />
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<Input type="datetime-local" {...register("end_date")} />
</div>
</div>
</SectionCard>
<div className="flex justify-between gap-2">
<Button
type="button"
variant="outline"
onClick={stepIndex === 0 ? () => navigate(-1) : handleBack}
disabled={loading}
>
<ChevronLeft className="size-4" />
{stepIndex === 0 ? "Cancel" : "Back"}
</Button>
<SectionCard title="Display" description="Manual ordering and on/off switch.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Order</Label>
<Input type="number" min={0} {...register("order")} />
</div>
<div className="flex items-center justify-between border rounded-md px-3 h-9">
<Label className="text-sm">Active</Label>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v)}
/>
</div>
</div>
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{stepIndex === steps.length - 1 ? (
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create advertisement
</Button>
</div>
</form>
) : (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="size-4" />
</Button>
)}
</div>
</div>
</div>
@@ -358,11 +620,12 @@ export default function AddAdvertisement() {
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => {
onSelect={(asset, resolvedUrl) => {
setSelectedAsset(asset);
setImagePreviewUrl(resolvedUrl ?? null);
setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
}}
/>
</section>
);
}
}
@@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom";
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
@@ -17,24 +18,27 @@ import {
} from "@/components/ui/alert-dialog";
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
export default function AdvertisementList() {
const navigate = useNavigate();
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
const [typeFilter, setTypeFilter] = useState("all");
const [placementFilter, setPlacementFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [search, setSearch] = useState("");
useEffect(() => {
const filters = [];
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
if (placementFilter !== "all") filters.push({ field: "placement", value: placementFilter });
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
fetchAdvertisements({ page: 1, limit: 24, filters });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeFilter, statusFilter, search]);
}, [typeFilter, placementFilter, statusFilter, search]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -82,7 +86,7 @@ export default function AdvertisementList() {
{/* ── Filters ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[150px]">
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
@@ -93,8 +97,20 @@ export default function AdvertisementList() {
</SelectContent>
</Select>
<Select value={placementFilter} onValueChange={setPlacementFilter}>
<SelectTrigger className="w-[220px] bg-background">
<SelectValue placeholder="All placements" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All placements</SelectItem>
{PLACEMENTS.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.pageLabel} — {p.slotLabel}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
@@ -109,7 +125,7 @@ export default function AdvertisementList() {
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search advertisements..."
className="pl-8"
className="pl-8 bg-background"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -164,11 +180,12 @@ function StatCard({ label, value, tone = "default" }) {
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
const { fmtDate } = useDateFormat();
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
const TypeIcon = typeMeta.icon ?? Megaphone;
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
const placementMeta = PLACEMENT_MAP[ad.placement] ?? null;
const TypeIcon = typeMeta.icon ?? Megaphone;
const previewSrc = ad.image?.thumbnail_url || ad.image?.file_url || ad.image_url || null;
const previewSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const isDimmed = ad.status === "expired" || ad.status === "archived";
const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDate);
@@ -178,13 +195,13 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
<button
type="button"
onClick={onView}
className="h-32 bg-muted relative flex items-center justify-center w-full text-left cursor-pointer"
className="h-32 bg-muted dark:bg-purple-950 relative flex items-center justify-center w-full text-left cursor-pointer"
aria-label="View advertisement details"
>
{previewSrc ? (
<img src={previewSrc} alt={ad.headline || ad.type} className="w-full h-full object-cover" />
) : (
<Megaphone className="size-7 text-muted-foreground" />
<Megaphone className="size-7" />
)}
<span className={`absolute top-2 left-2 text-xs font-medium px-2 py-0.5 rounded-md ${statusMeta.badgeClass ?? "bg-muted text-muted-foreground"}`}>
@@ -199,6 +216,11 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
<div className="p-3 flex flex-col gap-2 flex-1">
<button type="button" onClick={onView} className="text-left">
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled advertisement"}</p>
{placementMeta ? (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{placementMeta.pageLabel} — {placementMeta.slotLabel}</p>
) : (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">Unassigned placement</p>
)}
{dateRange && <p className="text-xs text-muted-foreground mt-0.5">{dateRange}</p>}
</button>
@@ -9,6 +9,7 @@ import { House, Plus, Trash2, ImagePlus } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -19,12 +20,13 @@ import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
placement: z.string().min(1, "Placement is required."),
badge_label: z.string().optional(),
headline: z.string().optional(),
description: z.string().optional(),
@@ -39,9 +41,10 @@ const schema = z.object({
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
size: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
}).superRefine((data, ctx) => {
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
const format = PLACEMENT_MAP[data.placement]?.format;
if (format === "hero" && (data.description?.length ?? 0) > 200) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 200,
@@ -86,6 +89,7 @@ const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
{ value: "xl", label: "Extra Large" },
];
const CTA_VARIANTS = [
@@ -103,6 +107,15 @@ export default function EditAdvertisement() {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
const [selectedPage, setSelectedPage] = useState(null);
// Gates the form's first paint until the fetched advertisement has been
// applied via reset() + setSelectedPage(). Without this, the Page/Position
// selects briefly mount with their empty defaultValues (no page selected,
// no position options yet) before the fetch resolves — that first paint is
// enough for the position <Select> to lose track of the eventual value,
// leaving it visually unselected even after reset() runs.
const [ready, setReady] = useState(false);
const {
register,
@@ -115,7 +128,7 @@ export default function EditAdvertisement() {
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
type: undefined,
placement: undefined,
badge_label: "",
headline: "",
description: "",
@@ -131,10 +144,12 @@ export default function EditAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const type = watch("type");
const placement = watch("placement");
const description = watch("description");
const showRichContent = RICH_CONTENT_TYPES.includes(type);
const isBanner = type === "banner";
const format = PLACEMENT_MAP[placement]?.format;
const showRichContent = RICH_CONTENT_TYPES.includes(format);
const isBanner = format === "banner";
const positionOptions = AD_PAGES.find((p) => p.page === selectedPage)?.placements ?? [];
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -149,10 +164,17 @@ export default function EditAdvertisement() {
const ad = res?.data?.data ?? null;
if (!ad) return;
if (ad.image) setSelectedAsset(ad.image);
if (ad.image) {
setSelectedAsset(ad.image);
// ad.image already carries a stream_token for S3-backed assets
// (minted server-side in controllers/admin/advertisements.controller.js)
// — no separate token round-trip needed.
setImagePreviewUrl(resolveAssetSrc(ad.image));
}
setSelectedPage(PLACEMENT_MAP[ad.placement]?.page ?? null);
reset({
type: ad.type ?? undefined,
placement: ad.placement ?? undefined,
badge_label: ad.badge_label ?? "",
headline: ad.headline ?? "",
description: ad.description ?? "",
@@ -168,6 +190,8 @@ export default function EditAdvertisement() {
is_active: ad.is_active ?? true,
size: ad.size ?? null,
});
setReady(true);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [advertisementId]);
@@ -180,7 +204,7 @@ export default function EditAdvertisement() {
image_asset_id: values.image_asset_id || null,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: values.type === "banner" ? (values.size || "md") : null,
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
updatedBy: user?.user_id ?? null,
};
@@ -188,6 +212,16 @@ export default function EditAdvertisement() {
if (res) navigate("/admin/advertisements");
};
if (!ready) {
return (
<section className="bg-muted h-full">
<div className="flex items-center justify-center py-32">
<Spinner className="size-6" />
</div>
</section>
);
}
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
@@ -202,26 +236,52 @@ export default function EditAdvertisement() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Placement" description="Where this advertisement will appear.">
<div>
<Label className="mb-1.5 block">Type</Label>
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true, shouldDirty: true })}>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{ADVERTISEMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.type?.message} />
{type && (
<p className="text-xs text-muted-foreground mt-1.5">
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Page</Label>
<Select
value={selectedPage ?? undefined}
onValueChange={(v) => {
setSelectedPage(v);
setValue("placement", "", { shouldValidate: false, shouldDirty: true });
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a page" />
</SelectTrigger>
<SelectContent>
{AD_PAGES.map((p) => (
<SelectItem key={p.page} value={p.page}>{p.pageLabel}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block">Position</Label>
<Select
value={placement || undefined}
onValueChange={(v) => setValue("placement", v, { shouldValidate: true, shouldDirty: true })}
disabled={!selectedPage}
>
<SelectTrigger>
<SelectValue placeholder={selectedPage ? "Select a position" : "Select a page first"} />
</SelectTrigger>
<SelectContent>
{positionOptions.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.slotLabel}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.placement?.message} />
</div>
</div>
{format && (
<p className="text-xs text-muted-foreground">
Format: <span className="font-medium text-foreground capitalize">{format}</span> — determined by the placement above.
</p>
)}
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
@@ -255,7 +315,7 @@ export default function EditAdvertisement() {
<div>
<Label className="mb-1.5 block">Description</Label>
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
{type === "hero" && (
{format === "hero" && (
<div className="flex justify-between items-start mt-1">
<FieldError message={errors.description?.message} />
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
@@ -283,7 +343,7 @@ export default function EditAdvertisement() {
onClick={() => setPickerOpen(true)}
>
<img
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
src={imagePreviewUrl || resolveAssetSrc(selectedAsset)}
alt={selectedAsset.display_name}
className="w-full h-full object-cover"
/>
@@ -400,8 +460,9 @@ export default function EditAdvertisement() {
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => {
onSelect={(asset, resolvedUrl) => {
setSelectedAsset(asset);
setImagePreviewUrl(resolvedUrl ?? null);
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
}}
/>
@@ -5,6 +5,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { House, Edit, ArrowLeft, Megaphone, MousePointerClick, ExternalLink } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
@@ -12,6 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { PLACEMENT_MAP } from "@/data/placement.data";
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -85,11 +87,12 @@ export default function ViewAdvertisement() {
);
}
const typeMeta = ADVERTISEMENT_TYPE_MAP[advertisement.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[advertisement.status] ?? {};
const TypeIcon = typeMeta.icon ?? Megaphone;
const typeMeta = ADVERTISEMENT_TYPE_MAP[advertisement.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[advertisement.status] ?? {};
const placementMeta = PLACEMENT_MAP[advertisement.placement] ?? null;
const TypeIcon = typeMeta.icon ?? Megaphone;
const previewSrc = advertisement.image?.thumbnail_url || advertisement.image?.file_url || advertisement.image_url || null;
const previewSrc = resolveAssetSrc(advertisement.image) || advertisement.image_url || null;
const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : [];
return (
@@ -111,7 +114,7 @@ export default function ViewAdvertisement() {
<h1 className="text-xl font-semibold tracking-tight">
{advertisement.headline || advertisement.badge_label || "Untitled advertisement"}
</h1>
<div className="flex items-center gap-1.5 mt-1">
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<Badge variant="secondary" className="gap-1">
<TypeIcon className="size-3" />
{typeMeta.label ?? advertisement.type}
@@ -119,6 +122,15 @@ export default function ViewAdvertisement() {
<Badge variant={advertisement.status === "active" ? "default" : "secondary"}>
{statusMeta.label ?? advertisement.status}
</Badge>
{placementMeta ? (
<Badge variant="outline">
{placementMeta.pageLabel} — {placementMeta.slotLabel}
</Badge>
) : (
<Badge variant="outline" className="border-amber-400 text-amber-700 dark:text-amber-400">
Unassigned placement
</Badge>
)}
</div>
</div>
</div>
@@ -7,11 +7,11 @@ import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
import { MediaFallback } from "@/components/generic/MediaFallback";
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
@@ -49,6 +49,14 @@ export default function ViewAudioAsset() {
return;
}
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// fetchAsset already embeds stream_token/thumbnail_url for S3 assets —
// only fall back to a token request if it's somehow missing (expired
// cache edge case).
if (selectedAsset.stream_token) {
setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
if (selectedAsset.thumbnail_url) setThumbnailUrl(selectedAsset.thumbnail_url);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => {
const { token, thumbnail_url } = data?.data ?? {};
@@ -59,11 +67,7 @@ export default function ViewAudioAsset() {
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Spinner className="size-8" />
</div>
);
return <MediaFallback className="h-96 rounded-lg" />;
}
if (!selectedAsset) {
@@ -43,10 +43,15 @@ export default function ViewDocumentAsset() {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// fetchAsset already embeds stream_token for S3 assets — only fall back
// to a token request if it's somehow missing (expired cache edge case).
if (selectedAsset.stream_token) {
setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
@@ -7,10 +7,10 @@ import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MediaFallback } from "@/components/generic/MediaFallback";
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
@@ -41,19 +41,20 @@ export default function ViewImageAsset() {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// fetchAsset already embeds stream_token for S3 assets — only fall back
// to a token request if it's somehow missing (expired cache edge case).
if (selectedAsset.stream_token) {
setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Spinner className="size-8" />
</div>
);
return <MediaFallback className="h-96 rounded-lg" />;
}
if (!selectedAsset) {
@@ -7,10 +7,10 @@ import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MediaFallback } from "@/components/generic/MediaFallback";
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
@@ -51,19 +51,20 @@ export default function ViewVideoAsset() {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// fetchAsset already embeds stream_token for S3 assets — only fall back
// to a token request if it's somehow missing (expired cache edge case).
if (selectedAsset.stream_token) {
setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Spinner className="size-8" />
</div>
);
return <MediaFallback className="h-96 rounded-lg" />;
}
if (!selectedAsset) {
+421 -333
View File
@@ -3,7 +3,10 @@ import { useEffect, useState } from "react";
import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, Plus, Trash2, BadgeCheck, Trophy, Check, ChevronsUpDown, X, ImagePlus, Palette } from "lucide-react";
import {
ArrowLeft, ArrowRight, Plus, Trash2, BadgeCheck, Trophy,
Check, ChevronsUpDown, X, ImagePlus, Palette, BookOpen,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
@@ -17,28 +20,16 @@ import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover, PopoverContent, PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,
} from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import { ACHIEVEMENT_REGISTRY } from "@/utils/achievements.data";
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
@@ -52,9 +43,16 @@ const schema = z.object({
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.string().min(1, "Subscription is required.").default("free"),
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
achievement_keys: z.array(z.string()).max(3).default([]),
achievement_keys: z.array(z.string()).max(1).default([]),
});
// ─── Steps config ─────────────────────────────────────────────────────────────
const STEPS = [
{ label: "Basic Info", description: "Title, level & objectives" },
{ label: "Rewards", description: "Badge & achievements" },
];
// ─── Helpers ──────────────────────────────────────────────────────────────────
function FieldError({ message }) {
@@ -76,6 +74,56 @@ function SectionCard({ title, description, children }) {
);
}
function StepIndicator({ steps, current, onStepClick }) {
return (
<div className="flex items-start w-full mb-8">
{steps.flatMap((step, i) => {
const items = [
<button
key={`step-${i}`}
type="button"
onClick={() => onStepClick(i)}
className="flex flex-col items-center gap-1.5 shrink-0 group"
>
<div
className={[
"w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-semibold transition-all group-hover:opacity-80",
i < current
? "bg-primary border-primary text-primary-foreground"
: i === current
? "border-primary text-primary"
: "border-border text-muted-foreground",
].join(" ")}
>
{i < current ? <Check className="h-4 w-4" /> : i + 1}
</div>
<p
className={[
"text-[11px] font-medium text-center leading-tight whitespace-nowrap",
i === current ? "text-foreground" : "text-muted-foreground",
].join(" ")}
>
{step.label}
</p>
</button>,
];
if (i < steps.length - 1) {
items.push(
<div
key={`line-${i}`}
className={[
"flex-1 h-px mt-4 mx-2 shrink",
i < current ? "bg-primary" : "bg-border",
].join(" ")}
/>
);
}
return items;
})}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AddCourse() {
@@ -83,6 +131,7 @@ export default function AddCourse() {
const { createCourse, loading } = useCourses();
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(0);
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
@@ -90,7 +139,6 @@ export default function AddCourse() {
.catch(() => {});
}, []);
// ─── Badge config state ─────────────────────────────────────────────────
const [badgeColor, setBadgeColor] = useState("purple");
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
const [badgeAssetId, setBadgeAssetId] = useState(null);
@@ -100,6 +148,7 @@ export default function AddCourse() {
const {
register,
handleSubmit,
trigger,
control,
setValue,
formState: { errors },
@@ -120,19 +169,34 @@ export default function AddCourse() {
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
useFieldArray({ control, name: "objectives" });
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
const watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscr = useWatch({ control, name: "subscription" });
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
const watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscr = useWatch({ control, name: "subscription" });
const [achievementRegistry, setAchievementRegistry] = useState([]);
useEffect(() => {
api.get("/admin/achievements")
.then(({ data }) => setAchievementRegistry((data.data ?? []).filter((a) => a.is_active)))
.catch(() => setAchievementRegistry([]));
}, []);
const toggleAchievement = (key) => {
if (currentAchKeys.includes(key)) {
setValue("achievement_keys", currentAchKeys.filter((k) => k !== key), { shouldDirty: true });
} else if (currentAchKeys.length < 3) {
setValue("achievement_keys", [...currentAchKeys, key], { shouldDirty: true });
} else {
setValue("achievement_keys", [key], { shouldDirty: true });
}
};
const handleNext = async () => {
if (currentStep === 0) {
const valid = await trigger(["title", "subscription", "objectives"]);
if (!valid) return;
}
setCurrentStep((s) => s + 1);
};
const onSubmit = async (values) => {
const payload = {
...values,
@@ -151,352 +215,376 @@ export default function AddCourse() {
};
return (
<section className="bg-muted/60 min-h-full">
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title="Add Course - STARR" description="Create a new training course." />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
{/* ── Sticky header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Add Course</h1>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<BookOpen className="h-5 w-5 text-muted-foreground" />
Add Course
</h1>
<p className="text-sm text-muted-foreground">Create a new training course.</p>
</div>
</div>
</div>
</div>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-2xl mx-auto">
<StepIndicator steps={STEPS} current={currentStep} onStepClick={setCurrentStep} />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* ── Basic Info ── */}
<SectionCard title="Basic Information">
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="course_code">Course Code</Label>
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
<FieldError message={errors.course_code?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="order_index">Order</Label>
<Input id="order_index" type="number" min={0} {...register("order_index")} />
<FieldError message={errors.order_index?.message} />
</div>
</div>
</SectionCard>
{/* ── Settings ── */}
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Level</Label>
<Select
value={watchedLevel ?? ""}
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner">Beginner</SelectItem>
<SelectItem value="intermediate">Intermediate</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
<FieldError message={errors.level?.message} />
</div>
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr ?? "free"}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select subscription" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.subscription?.message} />
</div>
</div>
</SectionCard>
{/* ── Objectives ── */}
<SectionCard
title="Learning Objectives"
description="What will learners be able to do after completing this course?"
>
<div className="space-y-2">
{objectiveFields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
placeholder={`Objective ${index + 1}`}
{...register(`objectives.${index}.text`)}
/>
<FieldError message={errors.objectives?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeObjective(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
{/* ── Step 0: Basic Info ── */}
{currentStep === 0 && (
<>
<SectionCard title="Basic Information">
<div className="space-y-1.5">
<Label htmlFor="title">
Title <span className="text-destructive">*</span>
</Label>
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full mt-1"
onClick={() => appendObjective({ text: "" })}
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="course_code">Course Code</Label>
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
<FieldError message={errors.course_code?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="order_index">Order</Label>
<Input id="order_index" type="number" min={0} {...register("order_index")} />
<FieldError message={errors.order_index?.message} />
</div>
</div>
</SectionCard>
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Level</Label>
<Select
value={watchedLevel ?? ""}
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner">Beginner</SelectItem>
<SelectItem value="intermediate">Intermediate</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
<FieldError message={errors.level?.message} />
</div>
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr ?? "free"}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select subscription" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.subscription?.message} />
</div>
</div>
</SectionCard>
<SectionCard
title="Learning Objectives"
description="What will learners be able to do after completing this course?"
>
<Plus className="h-4 w-4 mr-2" />
Add Objective
</Button>
</div>
</SectionCard>
{/* ── Rewards ── */}
<SectionCard
title="Rewards"
description="Badge and achievements awarded to learners who complete this course."
>
{/* ── Completion Badge ── */}
<div>
<p className="text-xs font-medium mb-3 text-muted-foreground uppercase tracking-wide">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={watchedTitle || "Course Title"}
level={watchedLevel}
color={badgeColor}
imageUrl={badgeImageUrl}
/>
<div className="flex-1 flex flex-col gap-3">
{/* Metadata */}
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Label:</span> Course Completion
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
</div>
<div className="flex items-center gap-1.5 pb-0.5">
<span className="font-medium text-foreground">Type:</span> Milestone achievement
</div>
<Badge className="self-start bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
{/* Color picker */}
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<Palette className="h-3 w-3" /> Color
</p>
<div className="flex flex-wrap gap-1.5">
{TIER_COLOR_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setBadgeColor(opt.key)}
className={[
"w-5 h-5 rounded-full border-2 transition-all",
badgeColor === opt.key
? "border-foreground scale-110 shadow-sm"
: "border-transparent hover:border-muted-foreground/50",
].join(" ")}
style={{ backgroundColor: opt.swatch }}
<div className="space-y-2">
{objectiveFields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
placeholder={`Objective ${index + 1}`}
{...register(`objectives.${index}.text`)}
/>
))}
</div>
</div>
{/* Image picker */}
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<ImagePlus className="h-3 w-3" /> Image <span className="normal-case font-normal">(optional)</span>
</p>
<div className="flex items-center gap-2">
{badgeImageUrl && (
<div className="w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<img src={badgeImageUrl} alt="" className="w-6 h-6 object-contain" />
</div>
)}
<FieldError message={errors.objectives?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAssetPickerOpen(true)}
className="h-7 text-xs"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeObjective(index)}
>
<ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"}
<Trash2 className="h-4 w-4" />
</Button>
{badgeImageUrl && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => { setBadgeImageUrl(null); setBadgeAssetId(null); }}
>
<X className="h-3 w-3 mr-1" /> Remove
</Button>
)}
</div>
</div>
</div>
</div>
</div>
))}
{/* ── Achievements ── */}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Achievements</p>
<span className="text-[10px] text-muted-foreground">{currentAchKeys.length}/3 selected</span>
</div>
{/* Selected badges */}
{currentAchKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentAchKeys.map((key) => {
const ach = ACHIEVEMENT_REGISTRY.find((a) => a.key === key);
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{ach?.label ?? key}
<button
type="button"
className="ml-0.5 rounded-full hover:bg-muted"
onClick={() => toggleAchievement(key)}
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
)}
{/* Popover picker */}
<Popover open={achOpen} onOpenChange={setAchOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-between"
disabled={currentAchKeys.length >= 3}
className="w-full mt-1"
onClick={() => appendObjective({ text: "" })}
>
<span className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
{currentAchKeys.length > 0
? `${currentAchKeys.length} selected — add more`
: "Select achievements"}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
<Plus className="h-4 w-4 mr-2" />
Add Objective
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search achievements…" />
<CommandList className="max-h-none">
<CommandEmpty>No achievements found.</CommandEmpty>
<CommandGroup>
<ScrollArea className="h-64">
{ACHIEVEMENT_REGISTRY.map((ach) => {
const checked = currentAchKeys.includes(ach.key);
const disabled = !checked && currentAchKeys.length >= 3;
return (
<CommandItem
key={ach.key}
value={ach.label}
disabled={disabled}
onSelect={() => !disabled && toggleAchievement(ach.key)}
className="gap-2 items-start py-2"
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-medium">{ach.label}</span>
<Badge variant="outline" className="text-[9px] px-1 py-0 gap-0.5 capitalize">
{ach.type === "badge"
? <Trophy className="h-2.5 w-2.5" />
: <BadgeCheck className="h-2.5 w-2.5" />
}
{ach.type}
</Badge>
</div>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
</div>
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />}
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</SectionCard>
</div>
</SectionCard>
</>
)}
{/* ── Actions ── */}
<div className="flex justify-end gap-3 pt-1">
{/* ── Step 1: Rewards ── */}
{currentStep === 1 && (
<SectionCard
title="Rewards"
description="Badge and achievements awarded to learners who complete this course."
>
{/* Completion Badge */}
<div>
<p className="text-xs font-medium mb-3 text-muted-foreground uppercase tracking-wide">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={watchedTitle || "Course Title"}
level={watchedLevel}
color={badgeColor}
imageUrl={badgeImageUrl}
/>
<div className="flex-1 flex flex-col gap-3">
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Label:</span> Course Completion
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
</div>
<div className="flex items-center gap-1.5 pb-0.5">
<span className="font-medium text-foreground">Type:</span> Milestone achievement
</div>
<Badge className="self-start bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<Palette className="h-3 w-3" /> Color
</p>
<div className="flex flex-wrap gap-1.5">
{TIER_COLOR_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setBadgeColor(opt.key)}
className={[
"w-5 h-5 rounded-full border-2 transition-all",
badgeColor === opt.key
? "border-foreground scale-110 shadow-sm"
: "border-transparent hover:border-muted-foreground/50",
].join(" ")}
style={{ backgroundColor: opt.swatch }}
/>
))}
</div>
</div>
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<ImagePlus className="h-3 w-3" /> Image <span className="normal-case font-normal">(optional)</span>
</p>
<div className="flex items-center gap-2">
{badgeImageUrl && (
<div className="w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<img src={badgeImageUrl} alt="" className="w-6 h-6 object-contain" />
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAssetPickerOpen(true)}
className="h-7 text-xs"
>
<ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"}
</Button>
{badgeImageUrl && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => { setBadgeImageUrl(null); setBadgeAssetId(null); }}
>
<X className="h-3 w-3 mr-1" /> Remove
</Button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Achievements */}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Achievements</p>
<span className="text-[10px] text-muted-foreground">{currentAchKeys.length > 0 ? "1 selected" : "none selected"}</span>
</div>
{currentAchKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentAchKeys.map((key) => {
const ach = achievementRegistry.find((a) => a.key === key);
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{ach?.label ?? key}
<button
type="button"
className="ml-0.5 rounded-full hover:bg-muted"
onClick={() => toggleAchievement(key)}
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
)}
<Popover open={achOpen} onOpenChange={setAchOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-between"
>
<span className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
{currentAchKeys.length > 0
? "Change achievement"
: "Select achievement"}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search achievements…" />
<CommandList className="max-h-none">
<CommandEmpty>No achievements found.</CommandEmpty>
<CommandGroup>
<ScrollArea className="h-64">
{achievementRegistry.map((ach) => {
const checked = currentAchKeys.includes(ach.key);
return (
<CommandItem
key={ach.key}
value={ach.label}
onSelect={() => {
toggleAchievement(ach.key);
setAchOpen(false);
}}
className="gap-2 items-start py-2"
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-medium">{ach.label}</span>
<Badge variant="outline" className="text-[9px] px-1 py-0 gap-0.5 capitalize">
{ach.type === "badge"
? <Trophy className="h-2.5 w-2.5" />
: <BadgeCheck className="h-2.5 w-2.5" />
}
{ach.type}
</Badge>
</div>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
</div>
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />}
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</SectionCard>
)}
{/* Asset picker (always mounted) */}
<AssetPickerSheet
open={assetPickerOpen}
onOpenChange={setAssetPickerOpen}
fileType="image"
onSelect={(asset, resolvedUrl) => {
setBadgeImageUrl(resolvedUrl ?? null);
setBadgeAssetId(asset.asset_id);
}}
/>
{/* ── Step navigation ── */}
<div className="flex items-center justify-between pt-2 pb-6">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
disabled={loading}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Course
<ArrowLeft className="h-4 w-4 mr-2" />
{currentStep === 0 ? "Cancel" : "Back"}
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Course
</Button>
)}
</div>
</form>
</div>
</div>
{/* Asset picker for badge image */}
<AssetPickerSheet
open={assetPickerOpen}
onOpenChange={setAssetPickerOpen}
fileType="image"
onSelect={(asset, resolvedUrl) => {
setBadgeImageUrl(resolvedUrl ?? null);
setBadgeAssetId(asset.asset_id);
}}
/>
</section>
</div>
);
}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
@@ -204,10 +205,12 @@ export default function CourseAssessment() {
const navigate = useNavigate();
const { courseId } = useParams();
const {
fetchAssessment, createAssessment, updateAssessment,
createAssessmentQuestion, updateAssessmentQuestion,
course, assessment, loading,
createAssessment, updateAssessment,
bulkSyncAssessmentQuestions,
course, loading,
} = useCourses();
const [localAssessment, setLocalAssessment] = useState(null);
const { user } = useAuth();
const [initializing, setInitializing] = useState(true);
@@ -236,30 +239,39 @@ export default function CourseAssessment() {
const navContainerRef = useRef(null);
const headerRef = useRef(null);
// ── Fetch ──────────────────────────────────────────────────────────────────
// ── Fetch — silently treat 404 as "no assessment yet" (create mode) ─────────
useEffect(() => {
(async () => {
await fetchAssessment(courseId);
setInitializing(false);
try {
const { data } = await api.get(`/admin/courses/${courseId}/assessment`);
const result = data?.data?.data ?? null;
setLocalAssessment(result);
} catch (err) {
if (err?.response?.status !== 404) {
toast.error(err?.response?.data?.message ?? "Could not load assessment.");
}
} finally {
setInitializing(false);
}
})();
}, [courseId]);
// ── Seed ──────────────────────────────────────────────────────────────────
useEffect(() => {
if (!assessment) return;
const t = assessment.title ?? "";
const ps = assessment.passing_score ?? 70;
const tl = assessment.time_limit_minutes ?? "";
const ir = assessment.is_required === true || assessment.is_required === 1;
const mq = assessment.max_questions ?? "";
const ma = assessment.max_attempts ?? 3;
const ch = assessment.cooldown_hours ?? 24;
const sq = assessment.shuffle_questions === true || assessment.shuffle_questions === 1;
const qs = (assessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
if (!localAssessment) return;
const t = localAssessment.title ?? "";
const ps = localAssessment.passing_score ?? 70;
const tl = localAssessment.time_limit_minutes ?? "";
const ir = localAssessment.is_required === true || localAssessment.is_required === 1;
const mq = localAssessment.max_questions ?? "";
const ma = localAssessment.max_attempts ?? 3;
const ch = localAssessment.cooldown_hours ?? 24;
const sq = localAssessment.shuffle_questions === true || localAssessment.shuffle_questions === 1;
const qs = (localAssessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, shuffleQuestions: sq, questions: qs });
}, [assessment]);
}, [localAssessment]);
// ── Measure sticky header → --assessment-h ────────────────────────────────
useEffect(() => {
@@ -379,7 +391,7 @@ export default function CourseAssessment() {
return;
}
const assessmentId = assessment?.assessment_id;
const assessmentId = localAssessment?.assessment_id;
const meta = {
title: title || "Course Assessment",
passing_score: passingScore,
@@ -420,18 +432,12 @@ export default function CourseAssessment() {
const res = await createAssessment(courseId, meta);
id = res?.data?.data?.data?.assessment_id;
if (!id) return;
setLocalAssessment((prev) => ({ ...prev, assessment_id: id }));
} else {
await updateAssessment(courseId, id, meta);
}
for (let i = 0; i < questions.length; i++) {
const q = { ...questions[i], order_index: i, updatedBy: user?.user_id };
if (q.question_id) {
await updateAssessmentQuestion(courseId, id, q.question_id, q);
} else {
await createAssessmentQuestion(courseId, id, q);
}
}
await bulkSyncAssessmentQuestions(courseId, id, questions, user?.user_id);
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
};
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,10 @@ import {
CheckCircle2, Circle, Users, Activity,
ChevronDown, ChevronUp,
} from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
@@ -338,27 +340,35 @@ export default function ViewAssessment() {
const { courseId } = useParams();
const {
fetchAssessment, assessment,
fetchAssessmentCompletions, fetchAssessmentSessions,
completions, sessions,
loading,
} = useCourses();
const [localAssessment, setLocalAssessment] = useState(null);
const [activeTab, setActiveTab] = useState("questions");
useEffect(() => {
fetchAssessment(courseId);
(async () => {
try {
const { data } = await api.get(`/admin/courses/${courseId}/assessment`);
setLocalAssessment(data?.data?.data ?? null);
} catch (err) {
if (err?.response?.status !== 404) {
toast.error(err?.response?.data?.message ?? "Could not load assessment.");
}
}
})();
}, [courseId]);
// Lazy-load completions/sessions the first time each tab is opened
const loadedRef = { completions: false, sessions: false };
useEffect(() => {
if (!assessment?.assessment_id) return;
if (activeTab === "completions") fetchAssessmentCompletions(courseId, assessment.assessment_id);
if (activeTab === "sessions") fetchAssessmentSessions(courseId, assessment.assessment_id);
}, [activeTab, assessment?.assessment_id]);
if (!localAssessment?.assessment_id) return;
if (activeTab === "completions") fetchAssessmentCompletions(courseId, localAssessment.assessment_id);
if (activeTab === "sessions") fetchAssessmentSessions(courseId, localAssessment.assessment_id);
}, [activeTab, localAssessment?.assessment_id]);
const questions = assessment?.questions ?? [];
const questions = localAssessment?.questions ?? [];
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return (
@@ -380,7 +390,7 @@ export default function ViewAssessment() {
<ClipboardList className="h-5 w-5 text-muted-foreground" />
View Assessment
</h1>
{assessment && (
{localAssessment && (
<p className="text-sm text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
@@ -393,7 +403,7 @@ export default function ViewAssessment() {
</div>
{/* Tabs */}
{assessment && (
{localAssessment && (
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
@@ -416,9 +426,9 @@ export default function ViewAssessment() {
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{loading && !assessment ? (
{loading && !localAssessment ? (
<LoadingSkeleton />
) : !assessment ? (
) : !localAssessment ? (
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<ClipboardList className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No assessment has been created for this course yet.</p>
@@ -431,23 +441,23 @@ export default function ViewAssessment() {
<>
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{assessment.title || "Course Assessment"}</InfoRow>
<InfoRow label="Title">{localAssessment.title || "Course Assessment"}</InfoRow>
<InfoRow label="Required">
<Badge variant={assessment.is_required ? "default" : "secondary"} className="mt-0.5">
{assessment.is_required ? "Required" : "Optional"}
<Badge variant={localAssessment.is_required ? "default" : "secondary"} className="mt-0.5">
{localAssessment.is_required ? "Required" : "Optional"}
</Badge>
</InfoRow>
<InfoRow label="Passing Score">{assessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Passing Score">{localAssessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit">
{assessment.time_limit_minutes ? `${assessment.time_limit_minutes} mins` : "No limit"}
{localAssessment.time_limit_minutes ? `${localAssessment.time_limit_minutes} mins` : "No limit"}
</InfoRow>
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
<InfoRow label="Max Shown per Attempt">
{assessment.max_questions ? `${assessment.max_questions} (random)` : `All (${questions.length})`}
{localAssessment.max_questions ? `${localAssessment.max_questions} (random)` : `All (${questions.length})`}
</InfoRow>
<InfoRow label="Total Points">{totalPoints}</InfoRow>
<InfoRow label="Max Failed Attempts">{assessment.max_attempts ?? 3}</InfoRow>
<InfoRow label="Cooldown After Fails">{assessment.cooldown_hours ?? 24}h</InfoRow>
<InfoRow label="Max Failed Attempts">{localAssessment.max_attempts ?? 3}</InfoRow>
<InfoRow label="Cooldown After Fails">{localAssessment.cooldown_hours ?? 24}h</InfoRow>
</div>
</SectionCard>
+297 -260
View File
@@ -11,7 +11,6 @@ import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import { ACHIEVEMENT_REGISTRY } from "@/utils/achievements.data";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -66,36 +65,261 @@ function LoadingSkeleton() {
);
}
// ─── Tab: Course Details ───────────────────────────────────────────────────────
function CourseDetailsTab({ course, loading, instructors, achievementKeys, achievementRegistry, badgeImageUrl }) {
const { fmtDateTime } = useDateFormat();
if (loading && !course) return <LoadingSkeleton />;
if (!course) return <div className="text-sm text-muted-foreground">Course not found.</div>;
return (
<div className="space-y-5">
<SectionCard icon={BookOpen} title="Basic Information">
<div className="space-y-4">
<InfoRow label="Title">{course.title}</InfoRow>
{course.description && (
<InfoRow label="Description">
<span className="whitespace-pre-wrap text-sm font-normal text-foreground">
{course.description}
</span>
</InfoRow>
)}
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Course Code">
{course.course_code ?? <span className="text-muted-foreground italic text-sm">—</span>}
</InfoRow>
<InfoRow label="Order Index">{course.order_index ?? 0}</InfoRow>
</div>
</div>
</SectionCard>
<SectionCard icon={Tag} title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Level">
{course.level ? (
<Badge variant={LEVEL_BADGE[course.level] ?? "outline"} className="capitalize mt-0.5">
{course.level}
</Badge>
) : null}
</InfoRow>
<InfoRow label="Subscription">
<Badge variant={SUBSCRIPTION_BADGE[course.subscription] ?? "outline"} className="capitalize mt-0.5">
{course.subscription ?? "free"}
</Badge>
</InfoRow>
</div>
</SectionCard>
<SectionCard icon={Clock} title="Duration & Stats">
<div className="grid grid-cols-3 gap-4">
<InfoRow label="Duration">
{course.duration_formatted ?? (course.duration_seconds ? `${course.duration_seconds}s` : "—")}
</InfoRow>
<InfoRow label="Units">
<div className="flex items-center gap-1.5 mt-0.5">
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
{course.unitCount ?? course.units?.length ?? 0}
</div>
</InfoRow>
<InfoRow label="Lessons">
<div className="flex items-center gap-1.5 mt-0.5">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
{course.lessonCount ?? 0}
</div>
</InfoRow>
</div>
</SectionCard>
{course.objectives?.length > 0 && (
<SectionCard icon={ListChecks} title="Learning Objectives">
<ul className="space-y-2">
{course.objectives.map((obj, i) => (
<li key={obj.objective_id ?? i} className="flex items-start gap-2 text-sm">
<BadgeCheck className="h-4 w-4 text-primary mt-0.5 shrink-0" />
{obj.text}
</li>
))}
</ul>
</SectionCard>
)}
<SectionCard icon={Users} title="Course Instructors">
{instructors.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No instructors assigned.</p>
) : (
<ul className="space-y-3">
{instructors.map((inst, i) => {
const fullName = inst.user?.personal_info?.name?.full_name ?? null;
const email = inst.user?.email ?? null;
return (
<li key={inst.id ?? i} className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">{inst.display_name}</p>
{(fullName || email) && (
<p className="text-xs text-muted-foreground truncate">
{fullName ?? email}
</p>
)}
</div>
<Badge variant="outline" className="ml-auto text-[10px] shrink-0">
#{i + 1}
</Badge>
</li>
);
})}
</ul>
)}
</SectionCard>
<SectionCard icon={Award} title="Rewards">
<div className="space-y-5">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={course.title}
level={course.level}
color={course.badge_color ?? "purple"}
imageUrl={badgeImageUrl}
/>
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground pt-1">
<div><span className="font-medium text-foreground">Label:</span> Course Completion</div>
<div><span className="font-medium text-foreground">Trigger:</span> Pass course assessment</div>
<div><span className="font-medium text-foreground">Type:</span> Milestone achievement</div>
<div><span className="font-medium text-foreground">Color:</span> <span className="capitalize">{course.badge_color ?? "purple"}</span></div>
<Badge className="self-start mt-1 bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
</div>
</div>
<div className="border-t pt-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">
Achievements
<span className="ml-2 normal-case font-normal">({achievementKeys.length}/3)</span>
</p>
{achievementKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No achievements assigned.</p>
) : (
<ul className="space-y-2">
{achievementKeys.map((key) => {
const ach = achievementRegistry.find((a) => a.key === key);
return (
<li key={key} className="flex items-start gap-2.5 text-sm">
<div className="mt-0.5 shrink-0">
{ach?.type === "badge"
? <Trophy className="h-4 w-4 text-amber-500" />
: <BadgeCheck className="h-4 w-4 text-primary" />
}
</div>
<div className="min-w-0">
<span className="font-medium">{ach?.label ?? key}</span>
{ach?.description && (
<p className="text-xs text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
)}
</div>
<Badge variant="outline" className="ml-auto text-[9px] capitalize shrink-0 mt-0.5">
{ach?.type ?? "badge"}
</Badge>
</li>
);
})}
</ul>
)}
</div>
</div>
</SectionCard>
{course.prerequisites?.length > 0 && (
<SectionCard icon={Star} title="Prerequisites">
<ul className="space-y-2">
{course.prerequisites.map((p, i) => (
<li key={p.prereq_id ?? i} className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="capitalize text-xs">{p.ref_type}</Badge>
<span className="text-muted-foreground">ID: {p.ref_id}</span>
</li>
))}
</ul>
</SectionCard>
)}
{course.assessment && (
<SectionCard icon={Lock} title="Final Assessment">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{course.assessment.title ?? "Untitled Assessment"}</InfoRow>
<InfoRow label="Passing Score">{course.assessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit">
{course.assessment.time_limit_minutes
? `${course.assessment.time_limit_minutes} mins`
: "No limit"}
</InfoRow>
</div>
</SectionCard>
)}
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow>
<InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow>
<InfoRow label="Created At">
{course.createdAt ? fmtDateTime(course.createdAt) : "—"}
</InfoRow>
<InfoRow label="Updated At">
{course.updatedAt ? fmtDateTime(course.updatedAt) : "—"}
</InfoRow>
</div>
</SectionCard>
</div>
);
}
// ─── Tabs config ───────────────────────────────────────────────────────────────
const TABS = [
{ key: "details", label: "Course Details", icon: BookOpen },
{ key: "progress", label: "Reading Progress", icon: BarChart2 },
];
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewCourse() {
const navigate = useNavigate();
const { courseId } = useParams();
const { fetchCourse, course, loading } = useCourses();
const { fmtDateTime } = useDateFormat();
const [activeTab, setActiveTab] = useState("details");
const [instructors, setInstructors] = useState([]);
const [achievementKeys, setAchievementKeys] = useState([]);
const [achievementRegistry, setAchievementRegistry] = useState([]);
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
useEffect(() => {
fetchCourse(courseId);
// Instructors
api.get(`/admin/courses/${courseId}/instructors`)
.then(({ data }) => setInstructors(data.data?.data ?? data.data ?? []))
.catch(() => {});
// Achievements
api.get(`/admin/courses/${courseId}/achievements`)
.then(({ data }) => {
const rows = data?.data?.data ?? [];
setAchievementKeys(rows.map((r) => r.achievement_key));
})
.catch(() => {});
api.get("/admin/achievements")
.then(({ data }) => setAchievementRegistry(data.data ?? []))
.catch(() => {});
}, [courseId]);
// Fresh stream token for the badge image once course loads
useEffect(() => {
if (!course?.badge_asset_id) {
setBadgeImageUrl(course?.badge_image_url ?? null);
@@ -112,269 +336,82 @@ export default function ViewCourse() {
}, [course?.badge_asset_id, course?.badge_image_url]);
return (
<section className="bg-muted/60 min-h-full">
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={course ? `${course.title} - STARR` : undefined} description={course?.description} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl">
{/* ── Header ── */}
<div className="flex items-start justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Course Details</h1>
<p className="text-sm text-muted-foreground">View course information.</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/edit`)}
disabled={loading}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
{/* ── Sticky header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<BookOpen className="h-5 w-5 text-muted-foreground" />
View Course
</h1>
{course && (
<p className="text-sm text-muted-foreground capitalize">
{course.course_code ? `${course.course_code} — ` : ""}{course.title}
</p>
)}
</div>
{activeTab === "details" && (
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/edit`)}
disabled={loading}
>
<Pencil className="h-4 w-4 mr-2" />
Edit Course
</Button>
)}
</div>
{loading && !course ? (
<LoadingSkeleton />
) : !course ? (
<div className="text-sm text-muted-foreground">Course not found.</div>
) : (
<div className="space-y-5">
{/* Underline tabs */}
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={[
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
</div>
{/* ── Basic Info ── */}
<SectionCard icon={BookOpen} title="Basic Information">
<div className="space-y-4">
<InfoRow label="Title">{course.title}</InfoRow>
{course.description && (
<InfoRow label="Description">
<span className="whitespace-pre-wrap text-sm font-normal text-foreground">
{course.description}
</span>
</InfoRow>
)}
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Course Code">
{course.course_code ?? <span className="text-muted-foreground italic text-sm">—</span>}
</InfoRow>
<InfoRow label="Order Index">{course.order_index ?? 0}</InfoRow>
</div>
</div>
</SectionCard>
{/* ── Settings ── */}
<SectionCard icon={Tag} title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Level">
{course.level ? (
<Badge variant={LEVEL_BADGE[course.level] ?? "outline"} className="capitalize mt-0.5">
{course.level}
</Badge>
) : null}
</InfoRow>
<InfoRow label="Subscription">
<Badge variant={SUBSCRIPTION_BADGE[course.subscription] ?? "outline"} className="capitalize mt-0.5">
{course.subscription ?? "free"}
</Badge>
</InfoRow>
</div>
</SectionCard>
{/* ── Duration & Stats ── */}
<SectionCard icon={Clock} title="Duration & Stats">
<div className="grid grid-cols-3 gap-4">
<InfoRow label="Duration">
{course.duration_formatted ?? (course.duration_seconds ? `${course.duration_seconds}s` : "—")}
</InfoRow>
<InfoRow label="Units">
<div className="flex items-center gap-1.5 mt-0.5">
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
{course.unitCount ?? course.units?.length ?? 0}
</div>
</InfoRow>
<InfoRow label="Lessons">
<div className="flex items-center gap-1.5 mt-0.5">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
{course.lessonCount ?? 0}
</div>
</InfoRow>
</div>
</SectionCard>
{/* ── Objectives ── */}
{course.objectives?.length > 0 && (
<SectionCard icon={ListChecks} title="Learning Objectives">
<ul className="space-y-2">
{course.objectives.map((obj, i) => (
<li key={obj.objective_id ?? i} className="flex items-start gap-2 text-sm">
<BadgeCheck className="h-4 w-4 text-primary mt-0.5 shrink-0" />
{obj.text}
</li>
))}
</ul>
</SectionCard>
)}
{/* ── Instructors ── */}
<SectionCard icon={Users} title="Course Instructors">
{instructors.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No instructors assigned.</p>
) : (
<ul className="space-y-3">
{instructors.map((inst, i) => {
const fullName = inst.user?.personal_info?.name?.full_name ?? null;
const email = inst.user?.email ?? null;
return (
<li key={inst.id ?? i} className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">{inst.display_name}</p>
{(fullName || email) && (
<p className="text-xs text-muted-foreground truncate">
{fullName ?? email}
</p>
)}
</div>
<Badge variant="outline" className="ml-auto text-[10px] shrink-0">
#{i + 1}
</Badge>
</li>
);
})}
</ul>
)}
</SectionCard>
{/* ── Rewards ── */}
<SectionCard icon={Award} title="Rewards">
<div className="space-y-5">
{/* Completion badge */}
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={course.title}
level={course.level}
color={course.badge_color ?? "purple"}
imageUrl={badgeImageUrl}
/>
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground pt-1">
<div><span className="font-medium text-foreground">Label:</span> Course Completion</div>
<div><span className="font-medium text-foreground">Trigger:</span> Pass course assessment</div>
<div><span className="font-medium text-foreground">Type:</span> Milestone achievement</div>
<div><span className="font-medium text-foreground">Color:</span> <span className="capitalize">{course.badge_color ?? "purple"}</span></div>
<Badge className="self-start mt-1 bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
</div>
</div>
{/* Achievements */}
<div className="border-t pt-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">
Achievements
<span className="ml-2 normal-case font-normal">({achievementKeys.length}/3)</span>
</p>
{achievementKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No achievements assigned.</p>
) : (
<ul className="space-y-2">
{achievementKeys.map((key) => {
const ach = ACHIEVEMENT_REGISTRY.find((a) => a.key === key);
return (
<li key={key} className="flex items-start gap-2.5 text-sm">
<div className="mt-0.5 shrink-0">
{ach?.type === "badge"
? <Trophy className="h-4 w-4 text-amber-500" />
: <BadgeCheck className="h-4 w-4 text-primary" />
}
</div>
<div className="min-w-0">
<span className="font-medium">{ach?.label ?? key}</span>
{ach?.description && (
<p className="text-xs text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
)}
</div>
<Badge variant="outline" className="ml-auto text-[9px] capitalize shrink-0 mt-0.5">
{ach?.type ?? "badge"}
</Badge>
</li>
);
})}
</ul>
)}
</div>
</div>
</SectionCard>
{/* ── Prerequisites ── */}
{course.prerequisites?.length > 0 && (
<SectionCard icon={Star} title="Prerequisites">
<ul className="space-y-2">
{course.prerequisites.map((p, i) => (
<li key={p.prereq_id ?? i} className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="capitalize text-xs">{p.ref_type}</Badge>
<span className="text-muted-foreground">ID: {p.ref_id}</span>
</li>
))}
</ul>
</SectionCard>
)}
{/* ── Assessment ── */}
{course.assessment && (
<SectionCard icon={Lock} title="Final Assessment">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{course.assessment.title ?? "Untitled Assessment"}</InfoRow>
{/* <InfoRow label="Required">
<Badge variant={course.assessment.is_required ? "default" : "secondary"}>
{course.assessment.is_required ? "Required" : "Optional"}
</Badge>
</InfoRow> */}
<InfoRow label="Passing Score">{course.assessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit">
{course.assessment.time_limit_minutes
? `${course.assessment.time_limit_minutes} mins`
: "No limit"}
</InfoRow>
</div>
</SectionCard>
)}
{/* ── Reading Progress ── */}
<SectionCard icon={BarChart2} title="Reading Progress">
<CourseReadingProgressList courseId={courseId} />
</SectionCard>
{/* ── Audit ── */}
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow>
<InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow>
<InfoRow label="Created At">
{course.createdAt ? fmtDateTime(course.createdAt) : "—"}
</InfoRow>
<InfoRow label="Updated At">
{course.updatedAt ? fmtDateTime(course.updatedAt) : "—"}
</InfoRow>
</div>
</SectionCard>
</div>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-3xl mx-auto pb-16">
{activeTab === "details" && (
<CourseDetailsTab
course={course}
loading={loading}
instructors={instructors}
achievementKeys={achievementKeys}
achievementRegistry={achievementRegistry}
badgeImageUrl={badgeImageUrl}
/>
)}
{activeTab === "progress" && (
<CourseReadingProgressList courseId={courseId} />
)}
</div>
</div>
</section>
</div>
);
}
@@ -54,7 +54,7 @@ export default function LessonsList() {
<div className="w-full space-y-6">
{/* ── Unit header ── */}
<div className="bg-white rounded-xl border p-6 space-y-4">
<div className="bg-card rounded-xl border p-6 space-y-4">
{/* Title + Status */}
<div>
<h6 className="text-xs tracking-widest mb-1">UNIT</h6>
@@ -70,21 +70,21 @@ export default function ViewLesson() {
{/* Stats row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="bg-white rounded-lg border p-3 space-y-1">
<div className="bg-card rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
<p className="font-semibold text-sm">#{lesson?.order_index ?? 0}</p>
</div>
<div className="bg-white rounded-lg border p-3 space-y-1">
<div className="bg-card rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium flex items-center gap-1">
<Clock className="h-3 w-3" /> Duration
</p>
<p className="font-semibold text-sm">{lesson?.duration_formatted ?? "0 mins"}</p>
</div>
<div className="bg-white rounded-lg border p-3 space-y-1">
<div className="bg-card rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
<p className="font-semibold text-sm">{fmtDate(lesson?.createdAt)}</p>
</div>
<div className="bg-white rounded-lg border p-3 space-y-1">
<div className="bg-card rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
<p className="font-semibold text-sm">{fmtDate(lesson?.updatedAt)}</p>
</div>
@@ -1,9 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown } from "lucide-react";
import { toast } from "sonner";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -196,10 +198,12 @@ export default function ModifyQuiz() {
const navigate = useNavigate();
const { courseId, unitId } = useParams();
const {
fetchQuiz, createQuiz, updateQuiz,
createQuizQuestion, updateQuizQuestion,
course, unit, quiz, loading,
createQuiz, updateQuiz,
bulkSyncQuizQuestions,
course, unit, loading,
} = useCourses();
const [localQuiz, setLocalQuiz] = useState(null);
const { user } = useAuth();
const [initializing, setInitializing] = useState(true);
@@ -227,26 +231,36 @@ export default function ModifyQuiz() {
{ label: "Quiz" },
];
// ── Fetch ──────────────────────────────────────────────────────────────────
// ── Fetch — silently treat 404 as "no quiz yet" (create mode) ─────────────
useEffect(() => {
(async () => {
await fetchQuiz(courseId, unitId);
setInitializing(false);
try {
const { data } = await api.get(`/admin/courses/${courseId}/units/${unitId}/quiz`);
const result = data?.data?.data ?? null;
setLocalQuiz(result);
} catch (err) {
if (err?.response?.status !== 404) {
toast.error(err?.response?.data?.message ?? "Could not load quiz.");
}
// 404 → no quiz yet, stay in create mode with localQuiz = null
} finally {
setInitializing(false);
}
})();
}, [courseId, unitId]);
// ── Seed ──────────────────────────────────────────────────────────────────
// ── Seed form from fetched quiz ────────────────────────────────────────────
useEffect(() => {
if (!quiz) return;
const t = quiz.title ?? "";
const ps = quiz.passing_score ?? 70;
const ir = quiz.is_required === true || quiz.is_required === 1;
const mq = quiz.max_questions ?? "";
const sq = quiz.shuffle_questions === true || quiz.shuffle_questions === 1;
const qs = (quiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
if (!localQuiz) return;
const t = localQuiz.title ?? "";
const ps = localQuiz.passing_score ?? 70;
const ir = localQuiz.is_required === true || localQuiz.is_required === 1;
const mq = localQuiz.max_questions ?? "";
const sq = localQuiz.shuffle_questions === true || localQuiz.shuffle_questions === 1;
const qs = (localQuiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, shuffleQuestions: sq, questions: qs });
}, [quiz]);
}, [localQuiz]);
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
useEffect(() => {
@@ -366,7 +380,7 @@ export default function ModifyQuiz() {
return;
}
let quizId = quiz?.quiz_id;
let quizId = localQuiz?.quiz_id;
const meta = {
title: title || "Unit Quiz",
passing_score: passingScore,
@@ -381,18 +395,12 @@ export default function ModifyQuiz() {
const res = await createQuiz(courseId, unitId, meta);
quizId = res?.data?.data?.data?.quiz_id;
if (!quizId) return;
setLocalQuiz((prev) => ({ ...prev, quiz_id: quizId }));
} else {
await updateQuiz(courseId, unitId, quizId, meta);
}
for (let i = 0; i < questions.length; i++) {
const q = { ...questions[i], order_index: i, updatedBy: user?.user_id };
if (q.question_id) {
await updateQuizQuestion(courseId, unitId, quizId, q.question_id, q);
} else {
await createQuizQuestion(courseId, unitId, quizId, q);
}
}
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id);
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
navigate(-1);
@@ -52,7 +52,7 @@ export default function UnitsList() {
<div className="w-full space-y-6">
{/* ── Course header ── */}
<div className="bg-white rounded-xl border p-6 space-y-4">
<div className="bg-card rounded-xl border p-6 space-y-4">
{/* Title + Status */}
<div>
<h6 className="text-xs tracking-widest mb-1">COURSE</h6>
@@ -0,0 +1,367 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import ReactMarkdown from "react-markdown";
import { ChevronRight, ChevronLeft, Check, Tags, FileText, Code2, ClipboardCheck, House, Eye, Send } from "lucide-react";
import { useAdminEmailTemplates, AdminEmailTemplateProvider } from "@/contexts/AdminEmailTemplateContext";
import { EMAIL_TEMPLATE_CATEGORIES, getEmailTemplateCategory } from "@/data/emailTemplateCategories.data";
import { markdownToHtml } from "@/utils/markdownToHtml.util";
import { MarkdownCheatsheet } from "@/components/generic/MarkdownCheatsheet";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
// ─── Zod schema ───────────────────────────────────────────────────────────────
// body_markdown is what the admin actually authors — converted to html_body
// (the column services/email.service.js reads) right before submission.
const emailTemplateSchema = z.object({
category: z.enum(["announcement", "advertisement", "system", "other"]),
type: z.string().min(1, "Type is required").regex(/^[A-Z][A-Z0-9_]*$/, "Uppercase letters, numbers or underscores only, starting with a letter."),
label: z.string().min(1, "Label is required"),
subject: z.string().min(1, "Subject is required"),
body_markdown: z.string().min(1, "Body is required"),
});
// ─── Steps ────────────────────────────────────────────────────────────────────
const STEPS = [
{ id: 0, label: "Category", icon: Tags, fields: ["category"] },
{ id: 1, label: "Details", icon: FileText, fields: ["type", "label"] },
{ id: 2, label: "Content", icon: Code2, fields: ["subject", "body_markdown"] },
{ id: 3, label: "Review", icon: ClipboardCheck, fields: [] },
];
const DEFAULT_VALUES = {
category: "",
type: "",
label: "",
subject: "",
body_markdown: "",
};
function Field({ label, required, error, children, hint }) {
return (
<div className="space-y-1.5">
<Label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</Label>
{children}
{hint && !error && <p className="text-xs text-muted-foreground">{hint}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
// ─── Step 1 — Category ────────────────────────────────────────────────────────
function StepCategory({ control, error }) {
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
What is this email for? This just helps organize templates in the list — it doesn't change how or when the email is sent.
</p>
<Controller
control={control}
name="category"
render={({ field }) => (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{EMAIL_TEMPLATE_CATEGORIES.map((cat) => {
const Icon = cat.icon;
const selected = field.value === cat.value;
return (
<button
key={cat.value}
type="button"
onClick={() => field.onChange(cat.value)}
className={cn(
"text-left rounded-lg border-2 p-4 transition-all flex items-start gap-3",
selected ? "border-foreground bg-muted" : "border-border hover:border-muted-foreground"
)}
>
<div className={cn("w-9 h-9 rounded-lg border flex items-center justify-center shrink-0", cat.badgeClass)}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold">{cat.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">{cat.description}</p>
</div>
</button>
);
})}
</div>
)}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
// ─── Step 2 — Details ─────────────────────────────────────────────────────────
function StepDetails({ register, errors, typeValue }) {
return (
<div className="space-y-4">
<Field
label="Type" required error={errors.type?.message}
hint="Uppercase, no spaces. This is the key your code passes to sendEmail({ type }) — cannot be changed after creation."
>
<Input
{...register("type", { setValueAs: (v) => v.toUpperCase() })}
placeholder="e.g. INVOICE_RECEIPT"
style={{ textTransform: "uppercase" }}
/>
</Field>
<Field label="Label" required error={errors.label?.message} hint="A friendly name shown in the admin list.">
<Input {...register("label")} placeholder="e.g. Invoice Receipt" />
</Field>
{typeValue && (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Custom templates aren't triggered automatically — a developer needs to call{" "}
<code className="bg-muted px-1 rounded">sendEmail({"{"} type: "{typeValue}", data {"}"})</code> from code.
</div>
)}
</div>
);
}
// ─── Step 3 — Content ─────────────────────────────────────────────────────────
function StepContent({ register, errors, bodyMarkdown }) {
const [showPreview, setShowPreview] = useState(false);
return (
<div className="space-y-4">
<Field label="Subject" required error={errors.subject?.message}>
<Input {...register("subject")} placeholder="e.g. Your Invoice - STARR System" />
</Field>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>Body <span className="text-destructive">*</span></Label>
<div className="flex items-center rounded-md border p-0.5">
<Button type="button" size="sm" variant={!showPreview ? "secondary" : "ghost"} className="h-7 px-2" onClick={() => setShowPreview(false)}>
<Code2 className="h-3.5 w-3.5 mr-1.5" /> Markdown
</Button>
<Button type="button" size="sm" variant={showPreview ? "secondary" : "ghost"} className="h-7 px-2" onClick={() => setShowPreview(true)}>
<Eye className="h-3.5 w-3.5 mr-1.5" /> Preview
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Write this in <strong>Markdown</strong> — it's converted to HTML automatically when you save (mandatory
storage format; only HTML is ever sent). Header, footer and signature are fixed and added automatically;
this box is just the message content in between. Reference dynamic values with{" "}
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens.
</p>
{showPreview ? (
<div className="rounded-md border bg-background p-4 min-h-[220px] text-sm" style={{ fontFamily: "Arial, sans-serif" }}>
{bodyMarkdown?.trim() ? <ReactMarkdown>{bodyMarkdown}</ReactMarkdown> : <p className="text-muted-foreground">Nothing to preview yet.</p>}
</div>
) : (
<Textarea {...register("body_markdown")} rows={12} className="font-mono text-xs" placeholder={"Dear {{name}},\n\nWelcome to **STARR System**!"} />
)}
{errors.body_markdown?.message && <p className="text-xs text-destructive">{errors.body_markdown.message}</p>}
<MarkdownCheatsheet />
</div>
</div>
);
}
// ─── Step 4 — Review ──────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm gap-4">
<span className="text-muted-foreground min-w-[100px] shrink-0">{label}</span>
<span className="text-foreground text-right break-words">{value}</span>
</div>
);
}
function StepReview({ data }) {
const cat = getEmailTemplateCategory(data.category);
const CatIcon = cat.icon;
return (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-3">
<ClipboardCheck className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Template Details</span>
<Badge variant="outline" className={cn("ml-auto gap-1 text-xs", cat.badgeClass)}>
<CatIcon className="h-3 w-3" /> {cat.label}
</Badge>
</div>
<SummaryRow label="Type" value={data.type} />
<SummaryRow label="Label" value={data.label} />
<SummaryRow label="Subject" value={data.subject} />
</div>
<div className="border border-border rounded-lg p-4">
<p className="text-sm font-medium mb-2">Body Preview</p>
<div className="rounded-md border bg-background p-4 text-sm" style={{ fontFamily: "Arial, sans-serif" }}>
{data.body_markdown?.trim() ? <ReactMarkdown>{data.body_markdown}</ReactMarkdown> : <p className="text-muted-foreground">No content.</p>}
</div>
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
function AddEmailTemplateInner() {
const navigate = useNavigate();
const { createTemplate, loading } = useAdminEmailTemplates();
const [step, setStep] = useState(0);
const {
register,
control,
trigger,
watch,
getValues,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(emailTemplateSchema),
defaultValues: DEFAULT_VALUES,
mode: "onTouched",
});
const typeValue = watch("type");
const bodyMarkdown = watch("body_markdown");
const handleNext = async () => {
const valid = await trigger(STEPS[step].fields.length ? STEPS[step].fields : undefined);
if (valid) setStep((s) => Math.min(s + 1, STEPS.length - 1));
};
// Called manually — no <form> tag so no accidental submit
const handleCreate = (publish) => handleSubmit(async (data) => {
// body_markdown is what the admin wrote; html_body is what actually
// gets stored/sent — mandatory HTML, converted right before submit.
const result = await createTemplate({ ...data, html_body: markdownToHtml(data.body_markdown), publish });
if (result) navigate("/admin/email-templates");
})();
return (
// ← plain div, no <form> — prevents any accidental submit on button clicks
<section className="bg-muted/60 min-h-full">
<PageMeta title="Add Email Template - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="max-w-2xl mx-auto w-full space-y-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Email Templates", to: "/admin/email-templates" },
{ label: "Add Template" },
]} />
<div>
<h1 className="text-xl font-semibold tracking-tight">Add Email Template</h1>
<p className="text-sm text-muted-foreground mt-1">
Define a new email type — category, details, and body content.
</p>
</div>
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
isDone && "bg-emerald-600 border-emerald-600 text-white",
isActive && "border-primary bg-primary text-primary-foreground",
!isActive && !isDone && "border-border bg-background text-muted-foreground"
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
isActive ? "text-foreground" : "text-muted-foreground",
isDone ? "text-emerald-600" : ""
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
"flex-1 h-px mx-2 mb-4 transition-colors",
step > i ? "bg-emerald-600" : "bg-border"
)} />
)}
</div>
);
})}
</div>
{/* Step content */}
<div className="border border-border rounded-xl p-5 bg-card min-h-[320px]">
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
{step === 0 && <StepCategory control={control} error={errors.category?.message} />}
{step === 1 && <StepDetails register={register} errors={errors} typeValue={typeValue} />}
{step === 2 && <StepContent register={register} errors={errors} bodyMarkdown={bodyMarkdown} />}
{step === 3 && <StepReview data={getValues()} />}
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={step === 0 ? () => navigate(-1) : () => setStep((s) => s - 1)}
>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
</Button>
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<div className="flex items-center gap-3">
<Button
type="button" // ← type="button", not "submit"
variant="outline"
disabled={loading}
onClick={() => handleCreate(false)} // ← called manually
>
Save as Draft
</Button>
<Button
type="button"
disabled={loading}
onClick={() => handleCreate(true)}
>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
Send Now
</Button>
</div>
)}
</div>
</div>
</div>
</section>
);
}
export default function AddEmailTemplate() {
return (
<AdminEmailTemplateProvider>
<AddEmailTemplateInner />
</AdminEmailTemplateProvider>
);
}
@@ -0,0 +1,297 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { ArrowLeft, House, Lock, Eye, Code2, Send, Clock3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminEmailTemplateProvider,
useAdminEmailTemplates,
} from "@/contexts/AdminEmailTemplateContext";
import { EMAIL_TEMPLATE_PLACEHOLDERS } from "@/data/emailTemplatePlaceholders.data";
import { EMAIL_TEMPLATE_CATEGORIES } from "@/data/emailTemplateCategories.data";
import { STATUS_META, hasPendingChanges } from "@/data/emailTemplateStatus.data";
import { markdownToHtml } from "@/utils/markdownToHtml.util";
import { MarkdownCheatsheet } from "@/components/generic/MarkdownCheatsheet";
import { cn } from "@/lib/utils";
function SectionCard({ title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
{children}
</div>
);
}
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function EditEmailTemplateInner() {
const navigate = useNavigate();
const { id } = useParams();
const { template, loading, fetchTemplate, updateTemplate } = useAdminEmailTemplates();
const [label, setLabel] = useState("");
const [category, setCategory] = useState("other");
const [subject, setSubject] = useState("");
const [bodyValue, setBodyValue] = useState(""); // Markdown source (markdown mode) or raw HTML (legacy mode)
const [errors, setErrors] = useState({});
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
if (id) fetchTemplate(id);
}, [id]);
useEffect(() => {
if (template) {
setLabel(template.label ?? "");
setCategory(template.category ?? "other");
// Prefer whatever's pending (unsent) over the live version, so
// reopening a template with pending changes resumes editing them.
setSubject(template.draft_subject ?? template.subject ?? "");
const markdown = template.draft_body_markdown ?? template.body_markdown;
setBodyValue(markdown ?? template.draft_html_body ?? template.html_body ?? "");
}
}, [template]);
const isSystem = template?.is_system;
const status = STATUS_META[template?.status] ?? STATUS_META.draft;
const pending = hasPendingChanges(template);
const knownPlaceholders = EMAIL_TEMPLATE_PLACEHOLDERS[template?.type] ?? null;
// Templates authored via the Markdown editor have a recorded Markdown
// source; templates from before that feature (all 8 system templates
// included) don't — those keep editing html_body/draft_html_body directly.
const isMarkdownMode = (template?.draft_body_markdown ?? template?.body_markdown) != null;
const validate = () => {
const e = {};
if (!label.trim()) e.label = "Label is required.";
if (!subject.trim()) e.subject = "Subject is required.";
if (!bodyValue.trim()) e.body = isMarkdownMode ? "Body is required." : "HTML body is required.";
setErrors(e);
return !Object.keys(e).length;
};
const handleSave = async (publish) => {
if (!validate()) return;
const payload = {
label: label.trim(),
category,
subject: subject.trim(),
publish,
};
if (isMarkdownMode) {
payload.body_markdown = bodyValue;
payload.html_body = markdownToHtml(bodyValue);
} else {
payload.html_body = bodyValue;
}
const result = await updateTemplate(id, payload);
if (result) navigate("/admin/email-templates");
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Edit Email Template - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Email Templates", to: "/admin/email-templates" },
{ label: template?.label ?? "Edit" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold">Edit Email Template</h1>
{template && (
<Badge variant="outline" className={cn("gap-1", status.badgeClass)}>
<Send className="h-3 w-3" /> {status.label}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">Update this email's category, subject and body.</p>
</div>
</div>
{pending && (
<div className="rounded-lg border border-amber-400 bg-amber-50 dark:bg-amber-950/40 dark:border-amber-700 p-4 flex items-start gap-3 mb-5">
<Clock3 className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-xs text-amber-800 dark:text-amber-300">
This template has <strong>pending changes</strong> that haven't gone out yet — the version
currently emailed to users is the last one you sent. Press <strong>Send</strong> below to
publish these edits, or <strong>Save as Draft</strong> to keep working without publishing.
</p>
</div>
)}
{isSystem && (
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
This is a <strong>system</strong> template — code sends it by referencing this exact type,
so the type is locked. Category, label, subject and body are still fully editable.
</p>
</div>
)}
<div className="space-y-5">
<SectionCard title="Template Details">
<div className="space-y-1.5">
<Label>Type</Label>
<Input value={template?.type ?? ""} disabled />
<p className="text-xs text-muted-foreground">Cannot be changed after creation.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Invoice Receipt" />
<FieldError message={errors.label} />
</div>
<div className="space-y-1.5">
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{EMAIL_TEMPLATE_CATEGORIES.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>{cat.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Organizational only — doesn't affect how or when this email is sent.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="subject">Subject <span className="text-destructive">*</span></Label>
<Input id="subject" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="e.g. Your Invoice - STARR System" />
<FieldError message={errors.subject} />
</div>
</SectionCard>
<SectionCard>
<div className="flex items-center justify-between border-b pb-3">
<p className="text-sm font-semibold">{isMarkdownMode ? "Body" : "HTML Body"}</p>
<div className="flex items-center rounded-md border p-0.5">
<Button
type="button" size="sm" variant={!showPreview ? "secondary" : "ghost"}
className="h-7 px-2" onClick={() => setShowPreview(false)}
>
<Code2 className="h-3.5 w-3.5 mr-1.5" /> {isMarkdownMode ? "Markdown" : "HTML"}
</Button>
<Button
type="button" size="sm" variant={showPreview ? "secondary" : "ghost"}
className="h-7 px-2" onClick={() => setShowPreview(true)}
>
<Eye className="h-3.5 w-3.5 mr-1.5" /> Preview
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground -mt-1">
{isMarkdownMode ? (
<>
Write this in <strong>Markdown</strong> — it's converted to HTML automatically when
you save (mandatory storage format; only HTML is ever sent). Header, footer and
signature are fixed and added automatically; this box is just the message content in between.
</>
) : (
<>
This template predates Markdown support, so it's edited as raw HTML directly — there's
no visual/drag-and-drop builder. Header, footer and signature are fixed and added
automatically; this box is just the message content in between.
</>
)}
</p>
{(knownPlaceholders !== null) && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Available placeholders</p>
{knownPlaceholders.length ? (
<div className="flex flex-wrap gap-1.5">
{knownPlaceholders.map((ph) => (
<Badge key={ph} variant="outline" className="font-mono text-[10px]">
{`{{${ph}}}`}
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">This template has no dynamic placeholders.</p>
)}
</div>
)}
{showPreview ? (
isMarkdownMode ? (
<div className="rounded-md border bg-background p-4 min-h-[220px] text-sm" style={{ fontFamily: "Arial, sans-serif" }}>
{bodyValue.trim() ? <ReactMarkdown>{bodyValue}</ReactMarkdown> : <p className="text-muted-foreground">Nothing to preview yet.</p>}
</div>
) : (
<div
className="rounded-md border bg-background p-4 min-h-[220px] text-sm"
style={{ fontFamily: "Arial, sans-serif" }}
dangerouslySetInnerHTML={{ __html: bodyValue || "<p class='text-muted-foreground'>Nothing to preview yet.</p>" }}
/>
)
) : (
<Textarea
id="body"
value={bodyValue}
onChange={(e) => setBodyValue(e.target.value)}
rows={14}
className="font-mono text-xs"
placeholder={isMarkdownMode ? "Dear {{name}},\n\nWelcome to **STARR System**!" : "<p>Dear {{name}},</p>"}
/>
)}
<FieldError message={errors.body} />
{isMarkdownMode && <MarkdownCheatsheet />}
</SectionCard>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => handleSave(false)} disabled={loading}>
Save as Draft
</Button>
<Button type="button" onClick={() => handleSave(true)} disabled={loading}>
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
Send
</Button>
</div>
</div>
</div>
</div>
</section>
);
}
export default function EditEmailTemplate() {
return (
<AdminEmailTemplateProvider>
<EditEmailTemplateInner />
</AdminEmailTemplateProvider>
);
}
@@ -0,0 +1,140 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { House, X, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminEmailBroadcastProvider,
useAdminEmailBroadcasts,
} from "@/contexts/AdminEmailBroadcastContext";
import { TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { EMAIL_BROADCAST_STATUS_MAP } from "@/data/emailBroadcastStatus.data";
import { cn } from "@/lib/utils";
const POLL_MS = 3000;
function ProgressBar({ sent, failed, total }) {
const donePct = total ? Math.min(100, ((sent + failed) / total) * 100) : 0;
const failedPct = total ? Math.min(100, (failed / total) * 100) : 0;
return (
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
<div className="h-full bg-emerald-500" style={{ width: `${donePct - failedPct}%` }} />
<div className="h-full bg-destructive" style={{ width: `${failedPct}%` }} />
</div>
);
}
function BroadcastRow({ item, onCancel }) {
const status = EMAIL_BROADCAST_STATUS_MAP[item.status] ?? EMAIL_BROADCAST_STATUS_MAP.queued;
const target = TARGET_TYPE_MAP[item.target_type];
const cancelable = item.status === "queued" || item.status === "sending";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold truncate">{item.template?.label ?? "(deleted template)"}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{target?.label ?? item.target_type}
{item.target_id && <span className="font-mono ml-1">#{item.target_id}</span>}
{" · "}
{new Date(item.createdAt).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className={cn("text-[11px]", status.badgeClass)}>{status.label}</Badge>
{cancelable && (
<Button type="button" variant="ghost" size="icon" onClick={() => onCancel(item)} title="Cancel">
<X className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<ProgressBar sent={item.sent_count} failed={item.failed_count} total={item.total_recipients} />
<p className="text-xs text-muted-foreground">
{item.sent_count} sent
{item.failed_count > 0 && <span className="text-destructive"> · {item.failed_count} failed</span>}
{" "}/ {item.total_recipients} total
</p>
</div>
);
}
function EmailBroadcastsInner() {
const navigate = useNavigate();
const { broadcasts, loading, fetchBroadcasts, fetchBroadcastsQuiet, cancelBroadcast } = useAdminEmailBroadcasts();
const pollRef = useRef(null);
useEffect(() => { fetchBroadcasts(); }, []);
useEffect(() => {
const hasActive = broadcasts.some((b) => b.status === "queued" || b.status === "sending");
if (hasActive && !pollRef.current) {
pollRef.current = setInterval(fetchBroadcastsQuiet, POLL_MS);
} else if (!hasActive && pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
}, [broadcasts, fetchBroadcastsQuiet]);
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Sent Email History - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-3xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Email Templates", to: "/admin/email-templates" },
{ label: "Sent History" },
]} />
</div>
<div className="mb-6">
<h1 className="text-xl font-semibold">Sent Email History</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Every broadcast queued from an email template, and how far delivery has gotten.
Sending is paced in the background — this page auto-refreshes while anything is in progress.
</p>
</div>
<Separator className="mb-5" />
{loading && !broadcasts.length ? (
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
) : !broadcasts.length ? (
<div className="text-center py-12 space-y-3">
<Send className="h-6 w-6 text-muted-foreground mx-auto" />
<p className="text-sm text-muted-foreground">No broadcasts sent yet.</p>
<Button size="sm" variant="outline" onClick={() => navigate("/admin/email-templates")}>
Back to Email Templates
</Button>
</div>
) : (
<div className="space-y-3">
{broadcasts.map((item) => (
<BroadcastRow key={item.email_broadcast_id} item={item} onCancel={(b) => cancelBroadcast(b.email_broadcast_id)} />
))}
</div>
)}
</div>
</div>
</section>
);
}
export default function EmailBroadcasts() {
return (
<AdminEmailBroadcastProvider>
<EmailBroadcastsInner />
</AdminEmailBroadcastProvider>
);
}
@@ -0,0 +1,272 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Plus, Pencil, Trash2, Mail, Lock, Send, Clock3, History } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminEmailTemplateProvider,
useAdminEmailTemplates,
} from "@/contexts/AdminEmailTemplateContext";
import { EMAIL_TEMPLATE_CATEGORIES, getEmailTemplateCategory, isBroadcastable } from "@/data/emailTemplateCategories.data";
import { STATUS_META, hasPendingChanges } from "@/data/emailTemplateStatus.data";
import { AdminEmailBroadcastProvider } from "@/contexts/AdminEmailBroadcastContext";
import { SendEmailBroadcastDialog } from "@/components/generic/SendEmailBroadcastDialog";
import { cn } from "@/lib/utils";
function TemplateCard({ item, onEdit, onDelete, onSend }) {
const cat = getEmailTemplateCategory(item.category);
const CatIcon = cat.icon;
const status = STATUS_META[item.status] ?? STATUS_META.draft;
const pending = hasPendingChanges(item);
return (
<div className="rounded-lg border bg-card p-5 flex flex-col gap-4 h-full">
<div className="flex items-start justify-between gap-2">
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center shrink-0">
<Mail className="h-4.5 w-4.5 text-muted-foreground" />
</div>
<div className="flex items-center gap-1 shrink-0">
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
{!item.is_system && (
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<p className="text-sm font-semibold truncate">{item.label}</p>
{item.is_system && (
<Badge variant="secondary" className="gap-1 shrink-0">
<Lock className="h-2.5 w-2.5" /> System
</Badge>
)}
</div>
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{item.type}</code>
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
<span className="text-foreground">{item.subject || item.draft_subject || "No subject yet"}</span>
</p>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<Badge variant="outline" className={cn("gap-1 text-[11px]", cat.badgeClass)}>
<CatIcon className="h-3 w-3" /> {cat.label}
</Badge>
<Badge variant="outline" className={cn("gap-1 text-[11px]", status.badgeClass)}>
<Send className="h-3 w-3" /> {status.label}
</Badge>
{pending && (
<Badge variant="outline" className="gap-1 text-[11px] bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700">
<Clock3 className="h-3 w-3" /> Pending changes
</Badge>
)}
</div>
{isBroadcastable(item) && (
<Button type="button" size="sm" variant="outline" className="gap-1.5" onClick={() => onSend(item)}>
<Send className="h-3.5 w-3.5" /> Send to Recipients
</Button>
)}
</div>
);
}
function EmailTemplatesInner() {
const navigate = useNavigate();
const { templates, loading, fetchTemplates, deleteTemplate } = useAdminEmailTemplates();
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
const [activeCategory, setActiveCategory] = useState("all");
const [sendTarget, setSendTarget] = useState(null);
useEffect(() => { fetchTemplates(); }, []);
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
await deleteTemplate(deleteTarget.email_template_id);
setDeleting(false);
setDeleteTarget(null);
};
const filtered = useMemo(
() => activeCategory === "all" ? templates : templates.filter((t) => t.category === activeCategory),
[templates, activeCategory]
);
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Email Templates - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-6xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Email Templates" },
]} />
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold">Email Templates</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Subject lines and message content for every automated email STARR sends.
</p>
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Send className="h-3 w-3 text-emerald-600" />
{templates.filter((t) => t.status === "sent").length} sent
</span>
<span className="flex items-center gap-1">
<Pencil className="h-3 w-3" />
{templates.filter((t) => t.status === "draft").length} draft
</span>
{templates.some(hasPendingChanges) && (
<span className="flex items-center gap-1 text-amber-600">
<Clock3 className="h-3 w-3" />
{templates.filter(hasPendingChanges).length} with pending changes
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => navigate("/admin/email-broadcasts")}>
<History className="h-4 w-4 mr-2" />
Sent History
</Button>
<Button size="sm" onClick={() => navigate("/admin/email-templates/add")}>
<Plus className="h-4 w-4 mr-2" />
Add Template
</Button>
</div>
</div>
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground space-y-1">
<p>
<strong>System</strong> templates are sent automatically by platform code and cannot be
deleted or have their type changed — the subject and body stay fully editable.
</p>
<p>
<strong>Draft vs. Sent:</strong> a <strong>Sent</strong> template is the version actually used
for real emails right now. Editing a Sent template doesn't change what goes out immediately —
it's held as a pending change until you press <strong>Send</strong> again to publish it. A
brand-new <strong>Draft</strong> isn't used for anything until it's sent for the first time.
</p>
<p>
<strong>Limitations:</strong> the page layout (header, footer, signature) is fixed and cannot
be customized from here — you can only edit the subject and the body content in between.
Only plain HTML is supported in the body (no visual/drag-and-drop builder) — no scripts and
no conditional logic, just straight <code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens
that get swapped for real values when the email is sent.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 mb-5">
<Button
type="button" size="sm" variant={activeCategory === "all" ? "secondary" : "outline"}
onClick={() => setActiveCategory("all")}
>
All ({templates.length})
</Button>
{EMAIL_TEMPLATE_CATEGORIES.map((cat) => {
const Icon = cat.icon;
const count = templates.filter((t) => t.category === cat.value).length;
return (
<Button
key={cat.value}
type="button" size="sm"
variant={activeCategory === cat.value ? "secondary" : "outline"}
onClick={() => setActiveCategory(cat.value)}
className="gap-1.5"
>
<Icon className="h-3.5 w-3.5" /> {cat.label} ({count})
</Button>
);
})}
</div>
<Separator className="mb-5" />
{loading && !templates.length ? (
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
) : !filtered.length ? (
<p className="text-sm text-muted-foreground text-center py-12">No email templates found.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((item) => (
<TemplateCard
key={item.email_template_id}
item={item}
onEdit={(t) => navigate(`/admin/email-templates/${t.email_template_id}/edit`)}
onDelete={(t) => setDeleteTarget(t)}
onSend={(t) => setSendTarget(t)}
/>
))}
</div>
)}
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Email Template</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{deleteTarget?.label}</span>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
{deleting && <Spinner className="h-4 w-4 mr-2" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Send to Recipients dialog */}
<SendEmailBroadcastDialog
open={!!sendTarget}
onOpenChange={(open) => { if (!open) setSendTarget(null); }}
template={sendTarget}
/>
</section>
);
}
export default function EmailTemplates() {
return (
<AdminEmailTemplateProvider>
<AdminEmailBroadcastProvider>
<EmailTemplatesInner />
</AdminEmailBroadcastProvider>
</AdminEmailTemplateProvider>
);
}
@@ -0,0 +1,182 @@
// modules/admin/pages/notifications/AddNotificationBroadcast.jsx
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { House } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
title: z.string().min(1, "Title is required."),
message: z.string().min(1, "Message is required."),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
target_id: z.string().nullable().optional(),
}).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Please select a specific target.",
path: ["target_id"],
});
}
});
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function SectionCard({ title, description, children }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-5">
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function AddNotificationBroadcast() {
const navigate = useNavigate();
const { createBroadcast, loading } = useNotificationBroadcasts();
const { user } = useAuth();
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
title: "",
message: "",
target_type: undefined,
target_id: null,
},
});
const targetType = watch("target_type");
const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Notifications", to: "/admin/notifications" },
{ label: "New" },
];
const onSubmit = async (values) => {
const payload = {
...values,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
createdBy: user?.user_id ?? null,
};
const res = await createBroadcast(payload);
if (res) navigate("/admin/notifications");
};
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">New notification</h1>
<p className="text-sm text-muted-foreground mb-6">Compose an announcement. It's saved as a draft until you send it.</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Content" description="What admins and/or users will see.">
<div>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div>
<Label className="mb-1.5 block">Message</Label>
<Textarea rows={4} placeholder="Full announcement text" {...register("message")} />
<FieldError message={errors.message?.message} />
</div>
</SectionCard>
<SectionCard title="Target" description="Who receives this notification when it's sent.">
<div>
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true });
setValue("target_id", null);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a target" />
</SelectTrigger>
<SelectContent>
{TARGET_TYPE_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.target_type?.message} />
{targetType && (
<p className="text-xs text-muted-foreground mt-1.5">
{TARGET_TYPE_MAP[targetType]?.description}
</p>
)}
</div>
{needsTarget && (
<div>
<BroadcastTargetPicker
targetType={targetType}
value={targetId}
onChange={(id) => setValue("target_id", id, { shouldValidate: true })}
/>
<FieldError message={errors.target_id?.message} />
</div>
)}
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save draft
</Button>
</div>
</form>
</div>
</div>
</section>
);
}
@@ -0,0 +1,202 @@
// modules/admin/pages/notifications/EditNotificationBroadcast.jsx
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { House } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
title: z.string().min(1, "Title is required."),
message: z.string().min(1, "Message is required."),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
target_id: z.string().nullable().optional(),
}).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Please select a specific target.",
path: ["target_id"],
});
}
});
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function SectionCard({ title, description, children }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-5">
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function EditNotificationBroadcast() {
const navigate = useNavigate();
const { broadcastId } = useParams();
const { fetchBroadcast, updateBroadcast, loading } = useNotificationBroadcasts();
const { user } = useAuth();
const {
register,
handleSubmit,
reset,
watch,
setValue,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
title: "",
message: "",
target_type: undefined,
target_id: null,
},
});
const targetType = watch("target_type");
const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Notifications", to: "/admin/notifications" },
{ label: "Edit" },
];
// ─── Load existing broadcast data ─────────────────────────────────────────
useEffect(() => {
(async () => {
const res = await fetchBroadcast(broadcastId);
const b = res?.data?.data ?? null;
if (!b) return;
reset({
title: b.title ?? "",
message: b.message ?? "",
target_type: b.target_type ?? undefined,
target_id: b.target_id ?? null,
});
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [broadcastId]);
const onSubmit = async (values) => {
const payload = {
...values,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
updatedBy: user?.user_id ?? null,
};
const res = await updateBroadcast(broadcastId, payload);
if (res) navigate("/admin/notifications");
};
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit notification</h1>
<p className="text-sm text-muted-foreground mb-6">Only draft notifications can be edited.</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Content" description="What admins and/or users will see.">
<div>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
<div>
<Label className="mb-1.5 block">Message</Label>
<Textarea rows={4} placeholder="Full announcement text" {...register("message")} />
<FieldError message={errors.message?.message} />
</div>
</SectionCard>
<SectionCard title="Target" description="Who receives this notification when it's sent.">
<div>
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true });
setValue("target_id", null);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a target" />
</SelectTrigger>
<SelectContent>
{TARGET_TYPE_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.target_type?.message} />
{targetType && (
<p className="text-xs text-muted-foreground mt-1.5">
{TARGET_TYPE_MAP[targetType]?.description}
</p>
)}
</div>
{needsTarget && (
<div>
<BroadcastTargetPicker
targetType={targetType}
value={targetId}
onChange={(id) => setValue("target_id", id, { shouldValidate: true })}
/>
<FieldError message={errors.target_id?.message} />
</div>
)}
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save changes
</Button>
</div>
</form>
</div>
</div>
</section>
);
}
@@ -0,0 +1,276 @@
// modules/admin/pages/notifications/NotificationBroadcastList.jsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Settings } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { TablePagination } from "@/components/generic/Table/TablePagination";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { TARGET_TYPE_MAP, BROADCAST_STATUSES, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
export default function NotificationBroadcastList() {
const navigate = useNavigate();
const { broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast } = useNotificationBroadcasts();
const [statusFilter, setStatusFilter] = useState("all");
const [search, setSearch] = useState("");
const [limit, setLimit] = useState(12);
function buildFilters() {
const filters = [];
if (statusFilter !== "all") filters.push({ id: "status", value: statusFilter });
if (search.trim()) filters.push({ id: "title", value: search.trim() });
return filters;
}
useEffect(() => {
fetchBroadcasts({ page: 1, limit, filters: buildFilters() });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, search, limit]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Notifications" },
];
const total = pagination?.totalRecords ?? broadcasts.length;
const draftCount = broadcasts.filter((b) => b.status === "draft").length;
const sentCount = broadcasts.filter((b) => b.status === "sent").length;
async function handleSend(broadcastId) {
await sendBroadcast(broadcastId);
}
async function handleArchive(broadcastId) {
await archiveBroadcast(broadcastId);
}
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={items} />
</div>
<div className="w-full flex flex-col gap-6 pb-10">
{/* ── Header ─────────────────────────────────────────────────── */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
<p className="text-sm text-muted-foreground">Compose and send announcements to admins and users</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => navigate("/admin/notifications/settings")}>
<Settings className="size-4" />
Settings
</Button>
<Button onClick={() => navigate("/admin/notifications/add")}>
<Plus className="size-4" />
New notification
</Button>
</div>
</div>
{/* ── Stat cards ─────────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<StatCard label="Total" value={total} />
<StatCard label="Drafts" value={draftCount} tone="muted" />
<StatCard label="Sent" value={sentCount} tone="success" />
</div>
{/* ── Filters ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{BROADCAST_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[160px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search notifications..."
className="pl-8 bg-background"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{/* ── Grid ───────────────────────────────────────────────────── */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner className="size-6" />
</div>
) : broadcasts.length === 0 ? (
<EmptyState onCreate={() => navigate("/admin/notifications/add")} />
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{broadcasts.map((b) => (
<BroadcastCard
key={b.broadcast_id}
broadcast={b}
onView={() => navigate(`/admin/notifications/${b.broadcast_id}/view`)}
onEdit={() => navigate(`/admin/notifications/${b.broadcast_id}/edit`)}
onSend={() => handleSend(b.broadcast_id)}
onArchive={() => handleArchive(b.broadcast_id)}
/>
))}
</div>
<div className="bg-background rounded-lg border">
<TablePagination
pagination={pagination}
rowCount={broadcasts.length}
totalRecords={pagination?.totalRecords}
recordLabel="notification"
pageSizeOptions={[12, 24, 48, 96]}
onPageChange={(page) => fetchBroadcasts({ page, limit, filters: buildFilters() })}
onPageSizeChange={(size) => setLimit(size)}
/>
</div>
</>
)}
</div>
</div>
</section>
);
}
// ─── Stat card ──────────────────────────────────────────────────────────────
function StatCard({ label, value, tone = "default" }) {
const toneClass = {
default: "text-foreground",
success: "text-green-600 dark:text-green-400",
muted: "text-muted-foreground",
}[tone];
return (
<div className="bg-background rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-1">{label}</p>
<p className={`text-2xl font-semibold ${toneClass}`}>{value}</p>
</div>
);
}
// ─── Broadcast card ─────────────────────────────────────────────────────────
function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
const { fmtDateTime } = useDateFormat();
const statusMeta = BROADCAST_STATUS_MAP[broadcast.status] ?? {};
const targetMeta = TARGET_TYPE_MAP[broadcast.target_type] ?? {};
const TargetIcon = targetMeta.icon ?? Users;
const targetText = broadcast.target_label ? `${targetMeta.label}: ${broadcast.target_label}` : (targetMeta.label ?? broadcast.target_type);
const isDraft = broadcast.status === "draft";
return (
<div className="bg-background rounded-lg border overflow-hidden flex flex-col">
<div className="p-3 flex flex-col gap-2 flex-1">
<button type="button" onClick={onView} className="text-left">
<div className="flex items-center gap-1.5 mb-1.5">
<span className={`text-xs font-medium px-2 py-0.5 rounded-md ${statusMeta.badgeClass ?? "bg-muted text-muted-foreground"}`}>
{statusMeta.label ?? broadcast.status}
</span>
<span className="flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-md bg-muted text-muted-foreground">
<TargetIcon className="size-3" />
{targetText}
</span>
</div>
<p className="text-sm font-medium leading-snug truncate hover:underline">{broadcast.title || "Untitled notification"}</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{broadcast.message}</p>
{broadcast.sent_at && (
<p className="text-xs text-muted-foreground mt-1.5">
Sent {fmtDateTime(broadcast.sent_at)} &middot; {broadcast.recipient_count ?? 0} recipient(s)
</p>
)}
</button>
<div className="mt-auto flex items-center justify-end gap-1 pt-2">
{isDraft && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-7" aria-label="Send">
<Send className="size-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send this notification?</AlertDialogTitle>
<AlertDialogDescription>
"{broadcast.title || "This notification"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onSend}>Send</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{isDraft && (
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
<Edit className="size-3.5" />
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-7" aria-label="Delete">
<Trash2 className="size-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive this notification?</AlertDialogTitle>
<AlertDialogDescription>
"{broadcast.title || "This notification"}" will be moved to archived notifications. You can restore it later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onArchive}>Archive</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
);
}
// ─── Empty state ────────────────────────────────────────────────────────────
function EmptyState({ onCreate }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
<Megaphone className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">No notifications yet</p>
<p className="text-sm text-muted-foreground">Compose your first announcement to admins or users.</p>
</div>
<Button onClick={onCreate}>
<Plus className="size-4" />
New notification
</Button>
</div>
);
}
@@ -0,0 +1,162 @@
// modules/admin/pages/notifications/NotificationSettings.jsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, ArrowLeft, Clock } from "lucide-react";
import { toast } from "sonner";
import api from "@/utils/api.util";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CRON_PRESET_OPTIONS, JOB_LABELS } from "@/data/cronPresets.data";
function SectionCard({ children }) {
return <div className="rounded-lg border bg-card p-4">{children}</div>;
}
export default function NotificationSettings() {
const navigate = useNavigate();
const { user } = useAuth();
const { fmtDateTime } = useDateFormat();
const [settings, setSettings] = useState([]);
const [loading, setLoading] = useState(true);
const [savingJob, setSavingJob] = useState(null);
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Notifications", to: "/admin/notifications" },
{ label: "Settings" },
];
async function fetchSettings() {
setLoading(true);
try {
const { data } = await api.get("/admin/notification-settings");
setSettings(data?.data ?? []);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Failed to load notification settings.");
} finally {
setLoading(false);
}
}
useEffect(() => { fetchSettings(); }, []);
async function handleToggle(jobName, enabled) {
setSavingJob(jobName);
try {
const { data } = await api.patch(`/admin/notification-settings/${jobName}`, {
enabled,
updatedBy: user?.user_id ?? null,
});
const updated = data?.data?.data;
setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated } : s)));
toast.success(`${JOB_LABELS[jobName]?.label ?? jobName} ${enabled ? "enabled" : "disabled"}.`);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Failed to update setting.");
} finally {
setSavingJob(null);
}
}
async function handlePresetChange(jobName, preset) {
setSavingJob(jobName);
try {
const { data } = await api.patch(`/admin/notification-settings/${jobName}`, {
preset,
updatedBy: user?.user_id ?? null,
});
const updated = data?.data?.data;
setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated, preset } : s)));
toast.success("Schedule updated — took effect immediately, no restart needed.");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Failed to update schedule.");
} finally {
setSavingJob(null);
}
}
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10 space-y-5">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/notifications")} aria-label="Back">
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-xl font-semibold tracking-tight">Notification Settings</h1>
<p className="text-sm text-muted-foreground">
Toggle and reschedule automatic notifications without a deploy.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner className="size-6" />
</div>
) : (
<div className="space-y-3">
{settings.map((s) => {
const meta = JOB_LABELS[s.job_name] ?? {};
const isSaving = savingJob === s.job_name;
return (
<SectionCard key={s.job_name}>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<p className="text-sm font-medium">{meta.label ?? s.label ?? s.job_name}</p>
<p className="text-xs text-muted-foreground mt-0.5">{meta.description ?? s.description}</p>
{s.updatedAt && (
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
<Clock className="size-3" />
Last updated {fmtDateTime(s.updatedAt)}
</p>
)}
</div>
<Switch
checked={s.enabled}
disabled={isSaving}
onCheckedChange={(v) => handleToggle(s.job_name, v)}
/>
</div>
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Runs:</span>
<Select
value={s.preset ?? undefined}
disabled={isSaving}
onValueChange={(v) => handlePresetChange(s.job_name, v)}
>
<SelectTrigger className="w-[180px] h-8 text-xs">
<SelectValue placeholder={s.schedule} />
</SelectTrigger>
<SelectContent>
{CRON_PRESET_OPTIONS.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
{isSaving && <Spinner className="size-3.5" />}
</div>
</SectionCard>
);
})}
</div>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,233 @@
// modules/admin/pages/notifications/ViewNotificationBroadcast.jsx
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Edit, ArrowLeft, Send, Users, FileText, BadgeCheck, Megaphone } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { TARGET_TYPE_MAP, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
// ─── Shared helpers ─────────────────────────────────────────────────────────
function SectionCard({ icon: Icon, title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
<Separator />
{children}
</div>
);
}
function Field({ label, children }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium">{children ?? <span className="text-muted-foreground italic">—</span>}</span>
</div>
);
}
// ─── Tab: Content ───────────────────────────────────────────────────────────
function ContentTab({ broadcast }) {
return (
<SectionCard icon={FileText} title="Content">
<Field label="Title">{broadcast.title || "—"}</Field>
<Field label="Message">
<span className="font-normal">{broadcast.message || "—"}</span>
</Field>
</SectionCard>
);
}
// ─── Tab: Delivery ───────────────────────────────────────────────────────────
function DeliveryTab({ broadcast, targetText, fmtDateTime }) {
return (
<SectionCard icon={Send} title="Delivery">
<div className="grid grid-cols-2 gap-4">
<Field label="Target">{targetText}</Field>
<Field label="Recipients">{broadcast.recipient_count ?? 0}</Field>
<Field label="Sent at">{broadcast.sent_at ? fmtDateTime(broadcast.sent_at) : "Not sent yet"}</Field>
</div>
</SectionCard>
);
}
// ─── Tab: Audit ───────────────────────────────────────────────────────────────
function AuditTab({ broadcast, fmtDateTime }) {
return (
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<Field label="Created by">{broadcast.creator?.full_name || "—"}</Field>
<Field label="Created at">{fmtDateTime(broadcast.createdAt)}</Field>
<Field label="Last updated by">{broadcast.updater?.full_name || "—"}</Field>
<Field label="Last updated at">{fmtDateTime(broadcast.updatedAt)}</Field>
</div>
</SectionCard>
);
}
// ─── Tabs config ───────────────────────────────────────────────────────────────
const TABS = [
{ key: "content", label: "Content", icon: FileText },
{ key: "delivery", label: "Delivery", icon: Send },
{ key: "audit", label: "Audit", icon: BadgeCheck },
];
// ─── Page ───────────────────────────────────────────────────────────────────
export default function ViewNotificationBroadcast() {
const navigate = useNavigate();
const { broadcastId } = useParams();
const { fetchBroadcast, sendBroadcast, loading } = useNotificationBroadcasts();
const { fmtDateTime } = useDateFormat();
const [broadcast, setBroadcast] = useState(null);
const [activeTab, setActiveTab] = useState("content");
useEffect(() => {
(async () => {
const res = await fetchBroadcast(broadcastId);
setBroadcast(res?.data?.data ?? null);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [broadcastId]);
if (loading && !broadcast) {
return (
<div className="flex items-center justify-center py-32">
<Spinner className="size-6" />
</div>
);
}
if (!broadcast) {
return (
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<p className="text-sm text-muted-foreground">Notification not found.</p>
</div>
);
}
const statusMeta = BROADCAST_STATUS_MAP[broadcast.status] ?? {};
const targetMeta = TARGET_TYPE_MAP[broadcast.target_type] ?? {};
const TargetIcon = targetMeta.icon ?? Users;
const targetText = broadcast.target_label ? `${targetMeta.label}: ${broadcast.target_label}` : (targetMeta.label ?? broadcast.target_type);
const isDraft = broadcast.status === "draft";
async function handleSend() {
const res = await sendBroadcast(broadcastId);
if (res) {
const refreshed = await fetchBroadcast(broadcastId);
setBroadcast(refreshed?.data?.data ?? broadcast);
}
}
return (
<div className="flex flex-col min-h-screen bg-muted/60">
{/* ── Sticky header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/notifications")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<Megaphone className="h-5 w-5 text-muted-foreground" />
{broadcast.title || "Untitled notification"}
</h1>
<div className="flex items-center gap-1.5 mt-1">
<Badge variant={broadcast.status === "sent" ? "default" : "secondary"}>
{statusMeta.label ?? broadcast.status}
</Badge>
<Badge variant="secondary" className="gap-1">
<TargetIcon className="size-3" />
{targetText}
</Badge>
</div>
</div>
{isDraft && (
<div className="flex items-center gap-2 shrink-0">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
<Send className="size-4" />
Send
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send this notification?</AlertDialogTitle>
<AlertDialogDescription>
"{broadcast.title || "This notification"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleSend}>Send</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button size="sm" onClick={() => navigate(`/admin/notifications/${broadcastId}/edit`)}>
<Edit className="size-4" />
Edit
</Button>
</div>
)}
</div>
{/* Underline tabs */}
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={[
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
</div>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{activeTab === "content" && <ContentTab broadcast={broadcast} />}
{activeTab === "delivery" && <DeliveryTab broadcast={broadcast} targetText={targetText} fmtDateTime={fmtDateTime} />}
{activeTab === "audit" && <AuditTab broadcast={broadcast} fmtDateTime={fmtDateTime} />}
</div>
</div>
</div>
);
}
@@ -1,34 +1,73 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import GroupMultiSelect from '@/components/generic/GroupMultiSelect';
import api from '@/utils/api.util';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowLeft } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, Users, ClipboardList } from 'lucide-react';
// ─── Steps ────────────────────────────────────────────────────────────────────
const STEPS = [
{ id: 0, label: 'Details', icon: FileText },
{ id: 1, label: 'Assign Groups', icon: Users },
{ id: 2, label: 'Review', icon: ClipboardList },
];
// ─── Summary row ──────────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm gap-4">
<span className="text-muted-foreground min-w-[120px] shrink-0">{label}</span>
<span className="text-foreground text-right">{value}</span>
</div>
);
}
export default function CreateTaskList() {
const navigate = useNavigate();
const { createTaskList, assignGroups, loading } = useAdminTask();
const [step, setStep] = useState(0);
const [form, setForm] = useState({ name: '', description: '' });
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
const [allGroups, setAllGroups] = useState([]);
const [errors, setErrors] = useState({});
const validate = () => {
// Fetch groups for the review step's summary (names, not just ids)
useEffect(() => {
api.get('/admin/groups', { params: { limit: 500 } })
.then((res) => setAllGroups(res.data?.data?.data ?? res.data?.data ?? []))
.catch(() => {});
}, []);
const validateDetails = () => {
const e = {};
if (!form.name.trim()) e.name = 'Task list name is required.';
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
const handleNext = () => {
if (step === 0 && !validateDetails()) return;
setStep((s) => s + 1);
};
const handleBack = () => {
if (step === 0) navigate('/admin/taskList');
else setStep((s) => s - 1);
};
const handleCreate = async () => {
if (!validateDetails()) { setStep(0); return; }
const created = await createTaskList({
name: form.name.trim(),
@@ -45,10 +84,17 @@ export default function CreateTaskList() {
navigate(`/admin/taskList/${created.task_list_id}/view`);
};
const selectedGroupNames = allGroups
.filter((g) => selectedGroupIds.includes(g.group_id))
.map((g) => g.name);
return (
// ← plain div, no <form> — prevents any accidental submit on button clicks
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="mx-auto">
<div className="flex items-center gap-3 mb-6">
<div className="mx-auto w-full lg:w-2xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate('/admin/taskList')}>
<ArrowLeft className="h-4 w-4" />
</Button>
@@ -57,37 +103,80 @@ export default function CreateTaskList() {
<p className="text-sm text-muted-foreground">View course information.</p>
</div>
</div>
<Card className="lg:w-2xl">
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div className="space-y-3">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g. Onboarding Tasks"
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name}</p>
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
'h-8 w-8 rounded-full flex items-center justify-center border transition-colors',
isDone && 'bg-emerald-600 border-emerald-600 text-white',
isActive && 'border-primary bg-primary text-primary-foreground',
!isActive && !isDone && 'border-border bg-background text-muted-foreground'
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
'text-[11px] font-medium whitespace-nowrap hidden sm:block',
isActive ? 'text-foreground' : 'text-muted-foreground',
isDone ? 'text-emerald-600' : ''
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
'flex-1 h-px mx-2 mb-4 transition-colors',
step > i ? 'bg-emerald-600' : 'bg-border'
)} />
)}
</div>
);
})}
</div>
{/* Description */}
<div className="space-y-3">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Optional description"
rows={3}
/>
{/* Step content */}
<Card>
<CardContent className="space-y-4 min-h-[280px]">
<h2 className="text-base font-medium">{STEPS[step].label}</h2>
{/* ── Step 1: Details ── */}
{step === 0 && (
<div className="space-y-4">
<div className="space-y-3">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g. Onboarding Tasks"
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name}</p>
)}
</div>
<div className="space-y-3">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Optional description"
rows={3}
/>
</div>
</div>
)}
{/* Groups */}
{/* ── Step 2: Assign Groups ── */}
{step === 1 && (
<div className="space-y-3">
<Label>
Assign to Groups
@@ -105,25 +194,64 @@ export default function CreateTaskList() {
Members of selected groups will be able to see and complete this task list.
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<Button
type="button"
variant="outline"
onClick={() => navigate('/admin/taskList')}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Creating…' : 'Create Task List'}
</Button>
{/* ── Step 3: Review ── */}
{step === 2 && (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Task List Details</span>
</div>
<SummaryRow label="Name" value={form.name || '—'} />
<SummaryRow label="Description" value={form.description || '—'} />
</div>
<div className="border border-border rounded-lg p-4 space-y-2">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Assigned Groups</span>
</div>
{selectedGroupNames.length > 0 ? (
<div className="flex flex-wrap gap-1.5 pt-1">
{selectedGroupNames.map((name) => (
<Badge key={name} variant="secondary" className="text-xs">{name}</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No groups assigned — task list will not be visible to any users yet.</p>
)}
</div>
</div>
</form>
)}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex items-center justify-between">
<Button type="button" variant="outline" onClick={handleBack}>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? 'Cancel' : 'Back'}
</Button>
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button
type="button" // ← type="button", not "submit"
disabled={loading}
onClick={handleCreate} // ← called manually
>
{loading ? 'Creating…' : 'Create Task List'}
</Button>
)}
</div>
</div>
</div >
</div>
);
}
}
@@ -1,7 +1,10 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { z } from 'zod';
import { format, parseISO, isValid } from 'date-fns';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import { cn } from '@/lib/utils';
import RequirementBuilder from './RequirementBuilder';
@@ -9,17 +12,74 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { ArrowLeft } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, ListChecks, ClipboardList, Link as LinkIcon, Upload, BookOpen, Layers, Clock } from 'lucide-react';
import DeadlinePicker from '@/components/generic/DeadlinePicker';
// ── Requirement validation schema ─────────────────────────────────────────────
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson'];
const requirementSchema = z.object({
type: z.string(),
reference_id: z.string().optional(),
duration_seconds: z.number().optional(),
}).passthrough().superRefine((req, ctx) => {
if (!READ_TYPES.includes(req.type)) return;
if (!req.reference_id) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please select content', path: ['reference_id'] });
} else if ((req.duration_seconds ?? -1) === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'This content has no content detected yet', path: ['duration_seconds'] });
}
});
const taskSchema = z.object({
name: z.string().min(1, 'Task name is required.'),
requirements: z.array(requirementSchema),
});
// ── Requirement type labels/icons for the review step ─────────────────────────
const REQUIREMENT_TYPE_META = {
visit_link: { label: 'Visit a Link', icon: LinkIcon },
upload_file: { label: 'Upload a File', icon: Upload },
read_course: { label: 'Read a Course', icon: BookOpen },
read_unit: { label: 'Read a Unit', icon: Layers },
read_lesson: { label: 'Read a Lesson', icon: FileText },
};
function requirementSummaryText(req) {
if (req.type === 'visit_link') return req.link_url || '—';
if (req.type === 'upload_file') {
const types = (req.allowed_file_types ?? []).join(', ').toUpperCase();
return `${types || 'Any type'} · max ${req.max_file_count ?? 1} file(s)`;
}
return req.reference_label || '—';
}
// ─── Steps ────────────────────────────────────────────────────────────────────
const STEPS = [
{ id: 0, label: 'Task Details', icon: FileText },
{ id: 1, label: 'Requirements', icon: ListChecks },
{ id: 2, label: 'Review', icon: ClipboardList },
];
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm gap-4">
<span className="text-muted-foreground min-w-[120px] shrink-0">{label}</span>
<span className="text-foreground text-right">{value}</span>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
export default function CreateTask() {
const navigate = useNavigate();
const { taskListId } = useParams();
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
const [step, setStep] = useState(0);
const [form, setForm] = useState({
name: '',
description: '',
@@ -28,7 +88,7 @@ export default function CreateTask() {
});
const [errors, setErrors] = useState({});
const [courses, setCourses] = useState([]);
const [units, setUnits] = useState([]);
const [units, setUnits] = useState([]);
const [lessons, setLessons] = useState([]);
useEffect(() => {
@@ -37,30 +97,71 @@ export default function CreateTask() {
fetchLessonsFlat().then((d) => d && setLessons(d));
}, []);
const validate = () => {
const e = {};
if (!form.name.trim()) e.name = 'Task name is required.';
setErrors(e);
return Object.keys(e).length === 0;
const validateStep = (s) => {
const schema = s === 0 ? taskSchema.pick({ name: true }) : taskSchema.pick({ requirements: true });
const result = schema.safeParse(form);
if (!result.success) {
const e = {};
const issues = result.error.issues;
if (s === 0) {
const nameIssue = issues.find((i) => i.path[0] === 'name');
if (nameIssue) e.name = nameIssue.message;
} else if (issues.some((i) => i.path[0] === 'requirements')) {
e.requirements = 'Some requirements have issues — check above.';
}
setErrors(e);
return false;
}
setErrors({});
return true;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
const handleNext = () => {
if (!validateStep(step)) return;
setStep((s) => s + 1);
};
const handleBack = () => {
if (step === 0) navigate(`/admin/taskList/${taskListId}/tasks`);
else setStep((s) => s - 1);
};
const handleCreate = async () => {
const result = taskSchema.safeParse(form);
if (!result.success) {
// Route back to whichever step has the problem
const issues = result.error.issues;
if (issues.some((i) => i.path[0] === 'name')) { setStep(0); validateStep(0); return; }
if (issues.some((i) => i.path[0] === 'requirements')) { setStep(1); validateStep(1); return; }
return;
}
const created = await createTask(taskListId, {
name: form.name.trim(),
description: form.description.trim() || null,
deadline: form.deadline || null,
requirements: form.requirements,
name: form.name.trim(),
description: form.description.trim() || null,
deadline: form.deadline || null,
// strip duration_seconds — it's only used for local validation
requirements: form.requirements.map((r) => {
const req = { ...r };
delete req.duration_seconds;
return req;
}),
});
if (created) navigate(`/admin/taskList/${taskListId}/tasks/${created.task_id}/view`);
if (created) navigate(`/admin/taskList/${taskListId}/tasks`);
};
const formattedDeadline = (() => {
if (!form.deadline) return null;
const d = parseISO(form.deadline);
return isValid(d) ? format(d, 'MMM d, yyyy h:mm a') : null;
})();
return (
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="mx-auto space-y-6">
<div className="mx-auto w-full lg:w-2xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
<ArrowLeft className="h-4 w-4" />
@@ -68,9 +169,46 @@ export default function CreateTask() {
<h1 className="text-xl font-semibold">Create Task</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Task info */}
<Card className="lg:w-2xl">
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
'h-8 w-8 rounded-full flex items-center justify-center border transition-colors',
isDone && 'bg-emerald-600 border-emerald-600 text-white',
isActive && 'border-primary bg-primary text-primary-foreground',
!isActive && !isDone && 'border-border bg-background text-muted-foreground'
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
'text-[11px] font-medium whitespace-nowrap hidden sm:block',
isActive ? 'text-foreground' : 'text-muted-foreground',
isDone ? 'text-emerald-600' : ''
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
'flex-1 h-px mx-2 mb-4 transition-colors',
step > i ? 'bg-emerald-600' : 'bg-border'
)} />
)}
</div>
);
})}
</div>
{/* ── Step 1: Task Details ── */}
{step === 0 && (
<Card>
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
@@ -95,7 +233,6 @@ export default function CreateTask() {
/>
</div>
{/* Deadline — date popover + time input */}
<div className="space-y-3">
<Label>Deadline</Label>
<DeadlinePicker
@@ -104,11 +241,12 @@ export default function CreateTask() {
disabled={loading}
/>
</div>
</CardContent>
</Card>
)}
{/* Requirements */}
{/* ── Step 2: Requirements ── */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Requirements</CardTitle>
@@ -116,7 +254,7 @@ export default function CreateTask() {
Define what a user needs to do to complete this task.
</p>
</CardHeader>
<CardContent>
<CardContent className="space-y-2">
<RequirementBuilder
value={form.requirements}
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
@@ -124,19 +262,86 @@ export default function CreateTask() {
units={units}
lessons={lessons}
/>
{errors.requirements && (
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
)}
</CardContent>
</Card>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
Cancel
{/* ── Step 3: Review ── */}
{step === 2 && (
<div className="space-y-4">
<Card>
<CardContent className="space-y-1 pt-6">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Task Details</span>
</div>
<SummaryRow label="Name" value={form.name || '—'} />
<SummaryRow label="Description" value={form.description || '—'} />
<SummaryRow
label="Deadline"
value={formattedDeadline
? <span className="inline-flex items-center gap-1"><Clock className="h-3.5 w-3.5" />{formattedDeadline}</span>
: 'No deadline'}
/>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-2 pt-6">
<div className="flex items-center gap-2">
<ListChecks className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Requirements</span>
<Badge variant="secondary" className="ml-auto text-xs">{form.requirements.length}</Badge>
</div>
{form.requirements.length === 0 ? (
<p className="text-sm text-muted-foreground">No requirements added — users will be able to complete this task immediately.</p>
) : (
<div className="space-y-2 pt-1">
{form.requirements.map((req, i) => {
const meta = REQUIREMENT_TYPE_META[req.type];
const Icon = meta?.icon ?? LinkIcon;
return (
<div key={i} className="flex items-center gap-2 border border-border rounded-lg px-3 py-2">
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Icon className="h-3 w-3" />
{i + 1}
</Badge>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{meta?.label ?? req.type}</p>
<p className="text-xs text-muted-foreground truncate">{requirementSummaryText(req)}</p>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<Button type="button" variant="outline" onClick={handleBack}>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? 'Cancel' : 'Back'}
</Button>
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
<Button type="submit" disabled={loading}>
) : (
<Button type="button" disabled={loading} onClick={handleCreate}>
{loading ? 'Creating…' : 'Create Task'}
</Button>
</div>
</form>
)}
</div>
</div>
</div>
);
}
}
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { z } from 'zod';
import { useAdminTask } from '@/contexts/AdminTaskContext';
@@ -12,8 +13,35 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ArrowLeft } from 'lucide-react';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ArrowLeft, TriangleAlert } from 'lucide-react';
// ── Requirement validation schema ─────────────────────────────────────────────
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson'];
const requirementSchema = z.object({
type: z.string(),
reference_id: z.string().optional(),
duration_seconds: z.number().optional(),
}).passthrough().superRefine((req, ctx) => {
if (!READ_TYPES.includes(req.type)) return;
if (!req.reference_id) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please select content', path: ['reference_id'] });
} else if ((req.duration_seconds ?? -1) === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'This content has no content detected yet', path: ['duration_seconds'] });
}
});
const taskSchema = z.object({
name: z.string().min(1, 'Task name is required.'),
requirements: z.array(requirementSchema),
});
// ─────────────────────────────────────────────────────────────────────────────
const STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending' },
{ value: 'in_progress', label: 'In Progress' },
@@ -31,10 +59,14 @@ export default function EditTask() {
const [courses, setCourses] = useState([]);
const [units, setUnits] = useState([]);
const [lessons, setLessons] = useState([]);
const [confirmOpen, setConfirmOpen] = useState(false);
const initialRequirementsRef = useRef(null);
useEffect(() => {
fetchTask(taskListId, taskId).then((data) => {
if (!data) return;
const reqs = data.requirements ?? [];
initialRequirementsRef.current = JSON.stringify(reqs);
setForm({
name: data.name ?? '',
description: data.description ?? '',
@@ -42,7 +74,7 @@ export default function EditTask() {
? new Date(data.deadline).toISOString().slice(0, 16)
: '',
status: data.status ?? 'pending',
requirements: data.requirements ?? [],
requirements: reqs,
});
});
fetchCoursesFlat().then((d) => d && setCourses(d));
@@ -50,26 +82,49 @@ export default function EditTask() {
fetchLessonsFlat().then((d) => d && setLessons(d));
}, [taskListId, taskId]);
const requirementsChanged = () =>
JSON.stringify(form?.requirements ?? []) !== initialRequirementsRef.current;
const validate = () => {
const e = {};
if (!form?.name?.trim()) e.name = 'Task name is required.';
setErrors(e);
return Object.keys(e).length === 0;
const result = taskSchema.safeParse(form ?? {});
if (!result.success) {
const e = {};
const issues = result.error.issues;
const nameIssue = issues.find((i) => i.path[0] === 'name');
if (nameIssue) e.name = nameIssue.message;
if (issues.some((i) => i.path[0] === 'requirements')) {
e.requirements = 'Some requirements have issues — check above.';
}
setErrors(e);
return false;
}
setErrors({});
return true;
};
const handleSubmit = async (e) => {
const doSave = async () => {
const updated = await updateTask(taskListId, taskId, {
name: form.name.trim(),
description: form.description.trim() || null,
deadline: form.deadline || null,
status: form.status,
// strip duration_seconds — it's only used for local validation
requirements: form.requirements.map(({ duration_seconds, ...req }) => req),
});
if (updated) navigate(`/admin/taskList/${taskListId}/tasks`);
};
const handleSubmit = (e) => {
e.preventDefault();
if (!validate()) return;
const updated = await updateTask(taskListId, taskId, {
name: form.name.trim(),
description: form.description.trim() || null,
deadline: form.deadline || null,
status: form.status,
requirements: form.requirements,
});
if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/view`);
if (requirementsChanged()) {
setConfirmOpen(true);
} else {
doSave();
}
};
if (!form) return (
@@ -81,6 +136,33 @@ export default function EditTask() {
);
return (
<>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<TriangleAlert className="size-4 text-amber-500" />
Requirements changed
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2 pt-1">
<span className="block">
You've modified the requirements for this task.
</span>
<span className="block">
All users currently assigned to this task will receive a notification
letting them know the requirements have been updated.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Go back</AlertDialogCancel>
<AlertDialogAction onClick={doSave} disabled={loading}>
{loading ? 'Saving…' : 'Confirm & Save'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="mx-auto space-y-6">
<div className="flex items-center gap-3">
@@ -150,7 +232,7 @@ export default function EditTask() {
<CardTitle className="text-base">Requirements</CardTitle>
<p className="text-sm text-muted-foreground">Changes here will replace existing requirements.</p>
</CardHeader>
<CardContent>
<CardContent className="space-y-2">
<RequirementBuilder
value={form.requirements}
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
@@ -158,6 +240,9 @@ export default function EditTask() {
units={units}
lessons={lessons}
/>
{errors.requirements && (
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
)}
</CardContent>
</Card>
@@ -173,5 +258,6 @@ export default function EditTask() {
</form>
</div>
</div>
</>
);
}
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Check } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Tag, Lock, Clock, Search, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -9,8 +8,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { ScrollArea } from '@/components/ui/scroll-area';
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { resolveTierBadge } from '@/utils/tierBadge.util';
import api from '@/utils/api.util';
// ─── Requirement type config ──────────────────────────────────────────────────
const REQUIREMENT_TYPES = [
@@ -34,54 +34,90 @@ const FILE_TYPE_OPTIONS = [
{ value: 'zip', label: 'ZIP' },
];
// ─── Searchable content picker (Popover + Command + ScrollArea) ───────────────
// renderItem — optional custom JSX per item (defaults to o[labelKey])
// searchKey — optional key whose value cmdk uses for filtering (defaults to labelKey)
function ContentPicker({ value, options, idKey, labelKey, searchKey, placeholder = 'Select…', onSelect, renderItem, listHeight = 'h-48' }) {
// ─── Duration formatter ───────────────────────────────────────────────────────
function fmtDuration(seconds) {
if (!seconds || seconds <= 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.round((seconds % 3600) / 60);
if (h > 0 && m > 0) return `${h}h ${m}m`;
if (h > 0) return `${h}h`;
return `${m}m`;
}
// ─── Tier badge ───────────────────────────────────────────────────────────────
function TierBadge({ subscription, tierMap }) {
const { rank, label, cls } = resolveTierBadge(subscription ?? 'free', tierMap);
return (
<Badge className={`${cls}`}>
{rank > 0 ? <Lock className="size-2.5" /> : <Tag className="size-2.5" />}
{label}
</Badge>
);
}
// ─── Custom content picker ────────────────────────────────────────────────────
function ContentPicker({ value, options, idKey, searchKey, labelKey, placeholder = 'Select…', onSelect, renderTrigger, renderItem, listHeight = 'max-h-48' }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) =>
String(o[searchKey] ?? o[labelKey] ?? '').toLowerCase().includes(q)
);
}, [options, query, searchKey, labelKey]);
const selected = options.find((o) => String(o[idKey]) === String(value));
const handleOpenChange = (v) => {
setOpen(v);
if (!v) setQuery('');
};
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-sm font-normal"
className="h-auto min-h-8 w-full justify-between text-sm font-normal py-1.5 px-3"
>
<span className="truncate">
{selected
? selected[labelKey]
: <span className="text-muted-foreground">{placeholder}</span>}
</span>
{selected
? renderTrigger(selected)
: <span className="text-muted-foreground">{placeholder}</span>}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search…" />
<CommandList className="max-h-none overflow-visible">
<ScrollArea className={listHeight}>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((o) => (
<CommandItem
key={o[idKey]}
value={searchKey ? o[searchKey] : o[labelKey]}
onSelect={() => {
onSelect(o);
setOpen(false);
}}
>
{renderItem ? renderItem(o) : o[labelKey]}
<Check className={cn('ml-auto h-4 w-4 shrink-0', String(value) === String(o[idKey]) ? 'opacity-100' : 'opacity-0')} />
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
<PopoverContent className="p-0" style={{ width: 'var(--radix-popover-trigger-width)' }} align="start">
<div className="flex items-center gap-2 border-b px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
className="flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className={`overflow-y-auto ${listHeight}`}>
{filtered.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No results found.</p>
) : (
filtered.map((o) => (
<div
key={o[idKey]}
role="option"
aria-selected={String(value) === String(o[idKey])}
className={`cursor-pointer select-none transition-colors hover:bg-accent hover:text-accent-foreground${String(value) === String(o[idKey]) ? ' bg-accent/50' : ''}`}
onClick={() => { onSelect(o); setOpen(false); setQuery(''); }}
>
{renderItem(o)}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
);
@@ -92,13 +128,10 @@ function createRequirement(type = 'visit_link') {
return {
_key: crypto.randomUUID(),
type,
// visit_link
link_url: '',
link_label: '',
// upload_file
allowed_file_types: [],
max_file_count: 1,
// read_*
reference_id: '',
reference_label: '',
};
@@ -111,17 +144,28 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
: []
);
const [tierCategories, setTierCategories] = useState([]);
const [lockedDialog, setLockedDialog] = useState(null); // { title, tierLabel, contentType }
const [noContentDialog, setNoContentDialog] = useState(null); // { title, contentType }
useEffect(() => {
api.get('/admin/tiers/categories')
.then(({ data }) => setTierCategories(data.data ?? []))
.catch(() => { });
}, []);
const tierMap = useMemo(
() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])),
[tierCategories]
);
const emit = (next) => {
setItems(next);
// strip _key before calling onChange
onChange?.(next.map(({ _key, ...r }) => r));
};
const addItem = () => emit([...items, createRequirement('visit_link')]);
const removeItem = (key) => emit(items.filter((i) => i._key !== key));
const updateItem = (key, patch) =>
emit(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
@@ -135,6 +179,20 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
updateItem(key, { allowed_file_types: next });
};
const handleContentSelect = (key, content, contentType) => {
updateItem(key, {
reference_id: content.uuid,
reference_label: content.title,
duration_seconds: content.duration_seconds ?? 0,
});
if ((content.duration_seconds ?? 0) === 0) {
setNoContentDialog({ title: content.title, contentType });
} else {
const { rank, label } = resolveTierBadge(content.subscription ?? 'free', tierMap);
if (rank > 0) setLockedDialog({ title: content.title, tierLabel: label, contentType });
}
};
return (
<div className="space-y-3">
{items.length === 0 && (
@@ -158,7 +216,6 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
{idx + 1}
</Badge>
{/* Type selector */}
<Select
value={item.type}
onValueChange={(v) => updateItem(item._key, { type: v })}
@@ -247,12 +304,12 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
{/* ── read_course / read_unit / read_lesson fields ── */}
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
<div className="pl-7 space-y-1">
<div className="space-y-1">
<Label className="text-xs">
{item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
</Label>
{/* Reference picker */}
{/* ── read_course picker ── */}
{item.type === 'read_course' && (
<ContentPicker
value={item.reference_id}
@@ -260,10 +317,33 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
idKey="uuid"
labelKey="title"
placeholder="Select a course"
onSelect={(c) => updateItem(item._key, { reference_id: c.uuid, reference_label: c.title })}
onSelect={(c) => handleContentSelect(item._key, c, 'course')}
renderTrigger={(c) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
<TierBadge subscription={c.subscription} tierMap={tierMap} />
<span className="flex-1 truncate text-sm">{c.title}</span>
{fmtDuration(c.duration_seconds) && (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(c.duration_seconds)}
</span>
)}
</div>
)}
renderItem={(c) => (
<div className="flex items-center gap-2 px-3 py-2">
<TierBadge subscription={c.subscription} tierMap={tierMap} />
<span className="flex-1 text-sm truncate">{c.title}</span>
{fmtDuration(c.duration_seconds) && (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(c.duration_seconds)}
</span>
)}
</div>
)}
/>
)}
{/* ── read_unit picker ── */}
{item.type === 'read_unit' && (
<ContentPicker
value={item.reference_id}
@@ -272,18 +352,44 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
labelKey="title"
searchKey="_search"
placeholder="Select a unit"
onSelect={(u) => handleContentSelect(item._key, u, 'unit')}
renderTrigger={(u) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
<TierBadge subscription={u.subscription} tierMap={tierMap} />
<div className="flex flex-col items-start flex-1">
<span className="text-xs leading-tight text-muted-foreground truncate">
{u.course_title} | Unit {u.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{u.title}</span>
</div>
{fmtDuration(u.duration_seconds) && (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(u.duration_seconds)}
</span>
)}
</div>
)}
renderItem={(u) => (
<div className="flex flex-col gap-0.5 py-0.5 min-w-0">
<span className="text-xs text-muted-foreground leading-tight truncate">
{u.course_title} &middot; Unit {u.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{u.title}</span>
<div className="flex items-center gap-2 px-3 py-2">
<TierBadge subscription={u.subscription} tierMap={tierMap} />
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm leading-tight text-muted-foreground truncate">
{u.course_title} (Course) | Unit {u.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{u.title}</span>
</div>
{fmtDuration(u.duration_seconds) && (
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(u.duration_seconds)}
</span>
)}
</div>
)}
onSelect={(u) => updateItem(item._key, { reference_id: u.uuid, reference_label: u.title })}
/>
)}
{/* ── read_lesson picker ── */}
{item.type === 'read_lesson' && (
<ContentPicker
value={item.reference_id}
@@ -292,22 +398,50 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
labelKey="title"
searchKey="_search"
placeholder="Select a lesson"
listHeight="h-64"
listHeight="max-h-64"
onSelect={(l) => handleContentSelect(item._key, l, 'lesson')}
renderTrigger={(l) => (
<div className="flex items-center gap-2 min-w-0 flex-1">
<TierBadge subscription={l.subscription} tierMap={tierMap} />
<div className="flex flex-col items-start flex-1">
<span className="text-xs leading-tight text-muted-foreground truncate">
{l.course_title} (Course) | Unit {l.unit_order + 1} | Lesson {l.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{l.title}</span>
</div>
{fmtDuration(l.duration_seconds) && (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(l.duration_seconds)}
</span>
)}
</div>
)}
renderItem={(l) => (
<div className="flex flex-col gap-0.5 py-0.5 min-w-0">
<span className="text-xs text-muted-foreground leading-tight truncate">
{l.course_title}
<span className="mx-1 opacity-50">›</span>
Unit {l.unit_order + 1}
<span className="mx-1 opacity-50">›</span>
Lesson {l.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{l.title}</span>
<div className="flex items-center gap-2 px-3 py-2">
<TierBadge subscription={l.subscription} tierMap={tierMap} />
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm leading-tight text-muted-foreground truncate">
{l.course_title} (Course) | Unit {l.unit_order + 1} | Lesson {l.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{l.title}</span>
</div>
{fmtDuration(l.duration_seconds) && (
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0">
<Clock className="size-3" />{fmtDuration(l.duration_seconds)}
</span>
)}
</div>
)}
onSelect={(l) => updateItem(item._key, { reference_id: l.uuid, reference_label: l.title })}
/>
)}
{/* ── inline no-content error ── */}
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && item.reference_id && (item.duration_seconds ?? -1) === 0 && (
<p className="flex items-center gap-1.5 text-xs text-destructive pl-7 pt-1">
<AlertTriangle className="size-3 shrink-0" />
This content has no content detected yet.
</p>
)}
</div>
)}
</CardContent>
@@ -319,6 +453,49 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
<Plus className="h-4 w-4" />
Add Requirement
</Button>
{/* ── Locked tier warning ─────────────────────────────────────────── */}
<AlertDialog open={!!lockedDialog} onOpenChange={(v) => !v && setLockedDialog(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Lock className="size-4 text-amber-500" />
Subscription Required
</AlertDialogTitle>
<AlertDialogDescription className="space-y-1">
<span className="block font-medium text-foreground">{lockedDialog?.title}</span>
<span className="block">
This {lockedDialog?.contentType} requires a <strong>{lockedDialog?.tierLabel}</strong> subscription.
Users without the required plan will not be able to complete this requirement.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setLockedDialog(null)}>Got it</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── No content warning ──────────────────────────────────────────── */}
<AlertDialog open={!!noContentDialog} onOpenChange={(v) => !v && setNoContentDialog(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-4 text-destructive" />
No Content Detected
</AlertDialogTitle>
<AlertDialogDescription className="space-y-1">
<span className="block font-medium text-foreground">{noContentDialog?.title}</span>
<span className="block">
This {noContentDialog?.contentType} does not have any content yet and cannot be used as a task requirement until content is added.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setNoContentDialog(null)}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
}
@@ -16,7 +16,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Pencil, Users, ListTodo, House } from 'lucide-react';
import { Pencil, Users, ListTodo, House, TriangleAlert } from 'lucide-react';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Spinner } from '@/components/ui/spinner';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import { formatDate } from '@/utils/table.util';
@@ -245,7 +251,7 @@ export default function Tasks() {
{/* ── All Groups Dialog ─────────────────────────────────────────── */}
<Dialog open={showGroupsDialog} onOpenChange={setShowGroupsDialog}>
<DialogContent className="max-w-sm">
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
@@ -267,16 +273,43 @@ export default function Tasks() {
</Dialog>
{/* ── Single archive ────────────────────────────────────────────── */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Task"
getName={(r) => r?.name}
onArchive={(entity) => archiveTask(taskListId, entity?.task_id)}
loading={loading}
onSuccess={afterMutation}
/>
<AlertDialog open={!!archiveTarget} onOpenChange={(v) => !v && setArchiveTarget(null)}>
<AlertDialogContent className="sm:max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>Archive Task</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
Are you sure you want to archive{' '}
<span className="font-medium text-foreground">{archiveTarget?.name}</span>?
This will deactivate the record immediately.
</p>
<div className="flex items-start gap-2 rounded-md border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/40 px-3 py-2.5 text-amber-800 dark:text-amber-300 text-sm">
<TriangleAlert className="size-4 mt-0.5 shrink-0" />
<p>
Check if users have already completed this task before archiving.
Review the <span className="font-medium">Completions</span> tab to avoid losing track of submitted work.
</p>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={loading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={async () => {
const ok = await archiveTask(taskListId, archiveTarget?.task_id);
if (ok) { setArchiveTarget(null); afterMutation(); }
}}
>
{loading && <Spinner className="size-4 mr-2" />}
Archive
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Single restore ────────────────────────────────────────────── */}
<RestoreDialog
+223 -96
View File
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House } from "lucide-react";
import { ArrowLeft, ArrowRight, Check, House } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -14,6 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -26,6 +27,7 @@ const DURATION_UNITS = [
{ value: "year", label: "Year(s)" },
];
const DURATION_UNIT_LIMITS = {
minute: { max: 59, nextLabel: "Hour(s)", factor: 60 },
hour: { max: 23, nextLabel: "Day(s)", factor: 24 },
@@ -66,24 +68,92 @@ function SectionCard({ title, children }) {
);
}
// ─── Steps config ─────────────────────────────────────────────────────────────
const STEPS = [
{ label: "Category & Label", description: "Tier category, label & description" },
{ label: "Duration & Pricing", description: "Billing period, price & currency" },
{ label: "Assigned Courses", description: "Choose which courses this unlocks" },
];
function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
return (
<div className="flex items-start w-full mb-8">
{steps.flatMap((step, i) => {
const reachable = i <= maxStepReached;
const items = [
<button
key={`step-${i}`}
type="button"
onClick={() => onStepClick(i)}
disabled={!reachable}
className="flex flex-col items-center gap-1.5 shrink-0 group disabled:cursor-not-allowed disabled:opacity-50"
>
<div
className={[
"w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-semibold transition-all",
reachable && "group-hover:opacity-80",
i < current
? "bg-primary border-primary text-primary-foreground"
: i === current
? "border-primary text-primary"
: "border-border text-muted-foreground",
].filter(Boolean).join(" ")}
>
{i < current ? <Check className="h-4 w-4" /> : i + 1}
</div>
<p
className={[
"text-[11px] font-medium text-center leading-tight whitespace-nowrap",
i === current ? "text-foreground" : "text-muted-foreground",
].join(" ")}
>
{step.label}
</p>
</button>,
];
if (i < steps.length - 1) {
items.push(
<div
key={`line-${i}`}
className={[
"flex-1 h-px mt-4 mx-2 shrink",
i < current ? "bg-primary" : "bg-border",
].join(" ")}
/>
);
}
return items;
})}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AddPlan() {
const navigate = useNavigate();
const { createPlan, loading } = useTiers();
const [categories, setCategories] = useState([]);
const [catLoading, setCatLoading] = useState(true);
const [currentStep, setCurrentStep] = useState(0);
const [maxStepReached, setMaxStepReached] = useState(0);
const [categories, setCategories] = useState([]);
const [catLoading, setCatLoading] = useState(true);
const [currencies, setCurrencies] = useState([]);
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [courseConflicts, setCourseConflicts] = useState(0);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setCategories((data.data ?? []).filter((c) => !c.is_default && c.is_active)))
.catch(() => {})
.finally(() => setCatLoading(false));
api.get("/admin/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
const { register, handleSubmit, trigger, setValue, watch, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { tier_category_id: "", label: "", description: "", duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
});
@@ -99,8 +169,29 @@ export default function AddPlan() {
// Reset picker when category changes
useEffect(() => {
setSelectedCourseIds(new Set());
setCourseConflicts(0);
}, [categorySlug]);
const STEP_FIELDS = [
["tier_category_id", "label", "description"],
["duration_value", "duration_unit", "price", "currency"],
[],
];
const handleNext = async () => {
const valid = await trigger(STEP_FIELDS[currentStep]);
if (!valid) return;
const next = Math.min(currentStep + 1, STEPS.length - 1);
setCurrentStep(next);
setMaxStepReached((s) => Math.max(s, next));
};
// Only allow jumping via the indicator to steps already reached through Next —
// prevents landing on "Assigned Courses" before a category is picked.
const handleStepClick = (i) => {
if (i <= maxStepReached) setCurrentStep(i);
};
const onSubmit = async (values) => {
const result = await createPlan(values);
if (!result) return;
@@ -139,115 +230,151 @@ export default function AddPlan() {
</div>
</div>
<StepIndicator steps={STEPS} current={currentStep} maxStepReached={maxStepReached} onStepClick={handleStepClick} />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Plan Details">
{/* ── Step 0: Category & Label ── */}
{currentStep === 0 && (
<SectionCard title="Category & Label" description="Which tier category this plan belongs to, and how it's presented.">
<div className="space-y-1.5">
<Label>Tier Category <span className="text-destructive">*</span></Label>
{catLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Spinner className="h-4 w-4" /> Loading categories…
</div>
) : categories.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No tier categories found.{" "}
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
</p>
) : (
<Select
value={selectedCategoryId}
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select tier category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
{c.name}
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FieldError message={errors.tier_category_id?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
<FieldError message={errors.label?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Brief description shown to users on the plans page."
rows={3}
{...register("description")}
/>
<FieldError message={errors.description?.message} />
</div>
<div className="space-y-1.5">
<Label>Duration <span className="text-destructive">*</span></Label>
<div className="flex gap-2">
<Input
id="duration_value"
type="number"
min={1}
className="flex-1"
{...register("duration_value")}
/>
<Select
value={watch("duration_unit") ?? "day"}
onValueChange={(v) => setValue("duration_unit", v, { shouldDirty: true })}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_UNITS.map((u) => (
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-1.5">
<Label>Tier Category <span className="text-destructive">*</span></Label>
{catLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Spinner className="h-4 w-4" /> Loading categories…
</div>
) : categories.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No tier categories found.{" "}
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
</p>
) : (
<Select
value={selectedCategoryId}
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select tier category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
{c.name}
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FieldError message={errors.tier_category_id?.message} />
</div>
<FieldError message={errors.duration_value?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
<FieldError message={errors.price?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
<FieldError message={errors.label?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Currency</Label>
<Input id="currency" maxLength={3} placeholder="USD" {...register("currency")} />
<FieldError message={errors.currency?.message} />
</div>
</SectionCard>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Brief description shown to users on the plans page."
rows={3}
{...register("description")}
/>
<FieldError message={errors.description?.message} />
</div>
</SectionCard>
)}
{categorySlug && (
<SectionCard title="Assigned Courses">
{/* ── Step 1: Duration & Pricing ── */}
{currentStep === 1 && (
<SectionCard title="Duration & Pricing" description="How long the plan lasts and what it costs.">
<div className="space-y-1.5">
<Label>Duration <span className="text-destructive">*</span></Label>
<div className="flex gap-2">
<Input
id="duration_value"
type="number"
min={1}
className="flex-1"
{...register("duration_value")}
/>
<Select
value={watch("duration_unit") ?? "day"}
onValueChange={(v) => setValue("duration_unit", v, { shouldDirty: true })}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_UNITS.map((u) => (
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FieldError message={errors.duration_value?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
<FieldError message={errors.price?.message} />
</div>
<div className="space-y-1.5">
<Label>Currency</Label>
<CurrencyPicker
value={watch("currency") ?? "USD"}
currencies={currencies}
onValueChange={(v) => setValue("currency", v, { shouldDirty: true })}
/>
<FieldError message={errors.currency?.message} />
</div>
</SectionCard>
)}
{/* ── Step 2: Assigned Courses ── */}
{currentStep === 2 && (
<SectionCard title="Assigned Courses" description="Choose which courses this plan unlocks.">
<CoursePicker
subscription={categorySlug}
selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds}
onConflictsChange={setCourseConflicts}
/>
</SectionCard>
)}
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading || catLoading || !selectedCategoryId}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Plan
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
disabled={loading}
>
{currentStep === 0 ? "Cancel" : "Back"}
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext} disabled={catLoading}>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={loading || catLoading || !selectedCategoryId || courseConflicts > 0}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Plan
</Button>
)}
</div>
</form>
+16 -3
View File
@@ -17,6 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
const DURATION_UNITS = [
@@ -27,6 +28,7 @@ const DURATION_UNITS = [
{ value: "year", label: "Year(s)" },
];
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
function durationDaysToValue(days, unit) {
@@ -82,6 +84,8 @@ export default function EditPlan() {
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [coursesLoaded, setCoursesLoaded] = useState(false);
const [courseConflicts, setCourseConflicts] = useState(0);
const [currencies, setCurrencies] = useState([]);
const [impactDialog, setImpactDialog] = useState(false);
const [impactCount, setImpactCount] = useState(0);
const [impactLoading, setImpactLoading] = useState(false);
@@ -93,6 +97,9 @@ export default function EditPlan() {
useEffect(() => {
fetchPlan(planId);
api.get("/admin/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, [planId]);
useEffect(() => {
@@ -250,8 +257,12 @@ export default function EditPlan() {
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Currency</Label>
<Input id="currency" maxLength={3} {...register("currency")} />
<Label>Currency</Label>
<CurrencyPicker
value={watch("currency") ?? "USD"}
currencies={currencies}
onValueChange={(v) => setValue("currency", v, { shouldDirty: true })}
/>
<FieldError message={errors.currency?.message} />
</div>
@@ -275,6 +286,8 @@ export default function EditPlan() {
selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds}
isPreloaded={true}
currentPlanId={planId}
onConflictsChange={setCourseConflicts}
/>
) : (
<div className="space-y-3">
@@ -290,7 +303,7 @@ export default function EditPlan() {
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading || impactLoading}>Cancel</Button>
<Button type="submit" disabled={loading || impactLoading}>
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0}>
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
</Button>
@@ -14,7 +14,7 @@ import * as LucideIcons from "lucide-react";
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
import { Badge } from "@/components/ui/badge";
const BADGE_ICON_OPTIONS = [
export const BADGE_ICON_OPTIONS = [
// Prestige / rank
{ name: "ShieldCheck", icon: ShieldCheck },
{ name: "Shield", icon: Shield },
@@ -1,525 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Globe, House, Plus, Trash2, Loader2, Pencil, Check, X } from "lucide-react";
import { toast } from "sonner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext";
import { useTiers } from "@/contexts/AdminTiersContext";
import api from "@/utils/api.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function SectionCard({ icon: Icon, title, description, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div>
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5 ml-6">{description}</p>}
</div>
<Separator />
{children}
</div>
);
}
const EMPTY_FORM = { currency: "", price: "" };
// ─── Rate-hint helpers ────────────────────────────────────────────────────────
const LOWER_HARD = 0.70;
const LOWER_WARN = 0.85;
const UPPER_WARN = 1.50;
const UPPER_HARD = 3.00;
function computeZone(price, hint) {
if (!hint || !price || Number(price) === 0) return null;
const n = Number(price);
if (isNaN(n)) return null;
if (n < hint.hardMin || n > hint.hardMax) return "block";
if (n < hint.warnMin || n > hint.warnMax) return "warn";
return "pass";
}
const ZONE_INPUT = {
block: "border-red-400 focus-visible:ring-red-400",
warn: "border-yellow-400 focus-visible:ring-yellow-400",
pass: "border-green-400 focus-visible:ring-green-400",
};
const ZONE_MSG = {
block: (h, c) => `Outside acceptable range: ${h.hardMin.toFixed(2)} – ${h.hardMax.toFixed(2)} ${c}`,
warn: (h, c) => `Outside suggested range: ${h.warnMin.toFixed(2)} – ${h.warnMax.toFixed(2)} ${c}. Will save with caution.`,
pass: () => `Price looks good.`,
};
const ZONE_TEXT = { block: "text-red-500", warn: "text-yellow-600", pass: "text-green-600" };
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function LocalizedPrices() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading: planLoading, plans, fetchPlans } = useTiers();
const [prices, setPrices] = useState([]);
const [pricesLoading, setPricesLoading] = useState(true);
const [currencies, setCurrencies] = useState([]);
// When accessed from toolbar (no planId), show plan picker first
const [selectedPlanId, setSelectedPlanId] = useState(planId ?? "");
const [addForm, setAddForm] = useState(EMPTY_FORM);
const [showAdd, setShowAdd] = useState(false);
const [adding, setAdding] = useState(false);
// Inline edit state: { [currency]: price }
const [editingRow, setEditingRow] = useState(null); // currency string
const [editPrice, setEditPrice] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [removingCurrency, setRemovingCurrency] = useState(null);
// Rate hint for the add form
const [rateHint, setRateHint] = useState(null);
const [rateHintLoading, setRateHintLoading] = useState(false);
// Rate hint for inline edit
const [editRateHint, setEditRateHint] = useState(null);
const activePlanId = planId ?? selectedPlanId;
const activePlan = plan?.plan_id === Number(activePlanId) ? plan
: plans.find((p) => String(p.plan_id) === String(activePlanId));
// ─── Load ──────────────────────────────────────────────────────────────────
const loadPrices = useCallback(async (id) => {
if (!id) return;
setPricesLoading(true);
try {
const { data } = await api.get(`/admin/tiers/${id}/prices`);
setPrices(data.data ?? []);
} catch {
toast.error("Could not load localized prices.");
} finally {
setPricesLoading(false);
}
}, []);
useEffect(() => {
api.get("/client/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
useEffect(() => {
if (!plans.length) fetchPlans();
}, []);
useEffect(() => {
if (!activePlanId) return;
fetchPlan(activePlanId);
loadPrices(activePlanId);
}, [activePlanId]);
// Fetch rate when currency is selected in the add form
useEffect(() => {
if (!addForm.currency || !activePlan) { setRateHint(null); return; }
setRateHintLoading(true);
fetch(`https://api.frankfurter.app/latest?from=${activePlan.currency}&to=${addForm.currency}`)
.then((r) => r.json())
.then((json) => {
const rate = json?.rates?.[addForm.currency];
if (!rate) { setRateHint(null); return; }
const expected = Number(activePlan.price) * rate;
setRateHint({ rate, expected, hardMin: expected * LOWER_HARD, hardMax: expected * UPPER_HARD,
warnMin: expected * LOWER_WARN, warnMax: expected * UPPER_WARN });
})
.catch(() => setRateHint(null))
.finally(() => setRateHintLoading(false));
}, [addForm.currency, activePlan?.plan_id]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch rate when opening an inline edit row
useEffect(() => {
if (!editingRow || !activePlan) { setEditRateHint(null); return; }
fetch(`https://api.frankfurter.app/latest?from=${activePlan.currency}&to=${editingRow}`)
.then((r) => r.json())
.then((json) => {
const rate = json?.rates?.[editingRow];
if (!rate) { setEditRateHint(null); return; }
const expected = Number(activePlan.price) * rate;
setEditRateHint({ rate, expected, hardMin: expected * LOWER_HARD, hardMax: expected * UPPER_HARD,
warnMin: expected * LOWER_WARN, warnMax: expected * UPPER_WARN });
})
.catch(() => setEditRateHint(null));
}, [editingRow, activePlan?.plan_id]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Actions ───────────────────────────────────────────────────────────────
const usedCurrencies = new Set(prices.map((p) => p.currency));
const availableCurrencies = currencies.filter(
(c) => !usedCurrencies.has(c.code) && c.code !== activePlan?.currency
);
const handleAdd = async () => {
if (!addForm.currency) { toast.error("Select a currency."); return; }
if (!addForm.price || Number(addForm.price) < 0) { toast.error("Enter a valid price."); return; }
const zone = computeZone(addForm.price, rateHint);
if (zone === "block") { toast.error("Price is outside the acceptable range. Adjust it before saving."); return; }
setAdding(true);
try {
const res = await api.post(`/admin/tiers/${activePlanId}/prices`, {
currency: addForm.currency,
price: Number(addForm.price),
});
if (res.data?.warning) toast.warning(res.data.message);
else toast.success("Localized price added.");
setAddForm(EMPTY_FORM);
setShowAdd(false);
setRateHint(null);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not add price.");
} finally {
setAdding(false);
}
};
const handleEditSave = async (currency) => {
if (editPrice === "" || Number(editPrice) < 0) { toast.error("Enter a valid price."); return; }
const zone = computeZone(editPrice, editRateHint);
if (zone === "block") { toast.error("Price is outside the acceptable range."); return; }
setSavingEdit(true);
try {
const res = await api.put(`/admin/tiers/${activePlanId}/prices/${currency}`, { price: Number(editPrice) });
if (res.data?.warning) toast.warning(res.data.message);
else toast.success("Price updated.");
setEditingRow(null);
setEditRateHint(null);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not update price.");
} finally {
setSavingEdit(false);
}
};
const handleRemove = async (currency) => {
setRemovingCurrency(currency);
try {
await api.delete(`/admin/tiers/${activePlanId}/prices/${currency}`);
toast.success(`${currency} price removed.`);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not remove price.");
} finally {
setRemovingCurrency(null);
}
};
// ─── Render ────────────────────────────────────────────────────────────────
const isLoading = planLoading || pricesLoading;
// ── Plan picker (toolbar entry, no planId in URL) ──────────────────────────
if (!planId) {
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Localized Prices - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: "Localized Prices" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Localized Prices</h1>
<p className="text-sm text-muted-foreground">Select a plan to manage its currency overrides.</p>
</div>
</div>
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="space-y-1.5">
<Label>Plan</Label>
<Select value={selectedPlanId} onValueChange={setSelectedPlanId}>
<SelectTrigger>
<SelectValue placeholder="Select a plan…" />
</SelectTrigger>
<SelectContent>
{plans.filter((p) => !p.deletedAt).map((p) => (
<SelectItem key={p.plan_id} value={String(p.plan_id)}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedPlanId && (
<Button onClick={() => navigate(`/admin/tiers/plans/${selectedPlanId}/prices`)}>
Manage Prices →
</Button>
)}
</div>
</div>
</div>
</section>
);
}
// ── Per-plan management ────────────────────────────────────────────────────
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={activePlan ? `Localized Prices — ${activePlan.label} - STARR` : "Localized Prices - STARR"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: activePlan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
{ label: "Localized Prices" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Localized Prices</h1>
<p className="text-sm text-muted-foreground capitalize">
{activePlan?.tier} — {activePlan?.label}
</p>
</div>
</div>
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-20 w-full" />)}
</div>
) : (
<SectionCard
icon={Globe}
title="Currency Overrides"
description={`Base price is ${activePlan?.currency ?? "USD"} ${Number(activePlan?.price ?? 0).toFixed(2)}. Overrides take priority when a user's preferred currency matches.`}
>
{/* ── Existing prices ─────────────────────────────────────── */}
{prices.length > 0 ? (
<div className="space-y-2">
{prices.map((entry) => {
const isEditing = editingRow === entry.currency;
const currencyMeta = currencies.find((c) => c.code === entry.currency);
return (
<div
key={entry.currency}
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Badge variant="outline" className="font-mono text-xs shrink-0">
{entry.currency}
</Badge>
{currencyMeta && (
<span className="text-xs text-muted-foreground shrink-0">
{currencyMeta.name}
</span>
)}
{isEditing ? (
<div className="flex flex-col gap-0.5">
{(() => {
const zone = computeZone(editPrice, editRateHint);
return (
<>
<Input
type="number"
step="0.01"
min="0"
className={`h-7 w-28 text-sm ${zone ? ZONE_INPUT[zone] : ""}`}
value={editPrice}
autoFocus
onChange={(e) => setEditPrice(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(entry.currency);
if (e.key === "Escape") { setEditingRow(null); setEditRateHint(null); }
}}
/>
{editRateHint && (
<p className="text-[10px] text-muted-foreground">
Good: {editRateHint.warnMin.toFixed(2)} – {editRateHint.warnMax.toFixed(2)}
</p>
)}
{zone && editPrice && (
<p className={`text-[10px] ${ZONE_TEXT[zone]}`}>
{zone === "block" ? "Out of range" : zone === "warn" ? "Caution" : ""}
</p>
)}
</>
);
})()}
</div>
) : (
<span className="text-sm font-semibold tabular-nums">
{Number(entry.price).toFixed(2)}
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
<>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-green-600 hover:text-green-500"
disabled={savingEdit}
onClick={() => handleEditSave(entry.currency)}
>
{savingEdit ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditingRow(null); setEditRateHint(null); }}
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditingRow(entry.currency); setEditPrice(String(entry.price)); }}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive"
disabled={removingCurrency === entry.currency}
onClick={() => handleRemove(entry.currency)}
>
{removingCurrency === entry.currency
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Trash2 className="h-3.5 w-3.5" />}
</Button>
</>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Globe className="size-4 shrink-0" />
No localized prices yet. All users see the base price.
</div>
)}
{/* ── Add form ────────────────────────────────────────────── */}
{showAdd ? (
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
<p className="text-sm font-medium">Add Localized Price</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Currency <span className="text-destructive">*</span></Label>
<Select
value={addForm.currency}
onValueChange={(v) => setAddForm((f) => ({ ...f, currency: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{availableCurrencies.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code} — {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Price <span className="text-destructive">*</span></Label>
{(() => {
const zone = computeZone(addForm.price, rateHint);
return (
<>
<Input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={addForm.price}
className={zone ? ZONE_INPUT[zone] : ""}
onChange={(e) => setAddForm((f) => ({ ...f, price: e.target.value }))}
/>
{rateHintLoading && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Fetching rate…
</p>
)}
{rateHint && !rateHintLoading && (
<p className="text-xs text-muted-foreground">
1 {activePlan.currency} ≈ {rateHint.rate.toFixed(4)} {addForm.currency}
{" · "}Good range: {rateHint.warnMin.toFixed(2)} – {rateHint.warnMax.toFixed(2)}
</p>
)}
{zone && addForm.price && (
<p className={`text-xs ${ZONE_TEXT[zone]}`}>
{ZONE_MSG[zone]?.(rateHint, addForm.currency)}
</p>
)}
</>
);
})()}
</div>
</div>
<div className="flex gap-2 justify-end pt-1">
<Button
variant="outline" size="sm"
onClick={() => { setShowAdd(false); setAddForm(EMPTY_FORM); }}
disabled={adding}
>
Cancel
</Button>
<Button size="sm" onClick={handleAdd} disabled={adding}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4 mr-1" />}
Add Price
</Button>
</div>
</div>
) : (
<Button
variant="outline" size="sm"
onClick={() => setShowAdd(true)}
disabled={availableCurrencies.length === 0}
>
<Plus className="h-4 w-4 mr-1" />
{availableCurrencies.length === 0 ? "All currencies configured" : "Add Currency"}
</Button>
)}
</SectionCard>
)}
</div>
</div>
</section>
);
}
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, CreditCard, Tag, ShieldCheck, Plus, Trash2, Loader2, Globe, ExternalLink } from "lucide-react";
import { ArrowLeft, House, Tag, ShieldCheck, Plus, Trash2, Loader2 } from "lucide-react";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { toast } from "sonner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -63,9 +63,6 @@ export default function PaymentPolicy() {
const [addForm, setAddForm] = useState(EMPTY_PROMO);
const [showAdd, setShowAdd] = useState(false);
// ── Localized prices (for notice in promo section)
const [localizedPrices, setLocalizedPrices] = useState([]);
// ─── Load ────────────────────────────────────────────────────────────────────
useEffect(() => {
@@ -87,9 +84,6 @@ export default function PaymentPolicy() {
.catch(() => {})
.finally(() => setPolicyLoading(false));
api.get(`/admin/tiers/${planId}/prices`)
.then(({ data }) => setLocalizedPrices(data.data ?? []))
.catch(() => {});
}, [planId]);
// ─── Save ────────────────────────────────────────────────────────────────────
@@ -182,24 +176,6 @@ export default function PaymentPolicy() {
) : (
<div className="space-y-5">
{/* ── Currency notice ───────────────────────────────────────── */}
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<CreditCard className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Plan base currency: <span className="font-mono">{plan?.currency ?? "USD"}</span>
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Flat promo code discounts are applied in <b>{plan?.currency ?? "USD"}</b>. If you need currency-specific pricing, configure overrides via{" "}
<b>Localized Prices</b> from the tier plan lists.
</p>
</div>
</div>
</div>
{/* ── Refund Policy ─────────────────────────────────────────── */}
<SectionCard
icon={ShieldCheck}
@@ -262,30 +238,6 @@ export default function PaymentPolicy() {
title="Promo Codes"
description="Define discount codes users can apply at checkout. Flat reduces price by a fixed amount; percent reduces by a percentage."
>
{/* Localized price notice */}
{localizedPrices.length > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 p-3">
<Globe className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="text-xs text-amber-800 dark:text-amber-300 space-y-0.5">
<p className="font-semibold">
This plan has {localizedPrices.length} localized price{localizedPrices.length > 1 ? "s" : ""} set
{" "}({localizedPrices.map((p) => p.currency).join(", ")}).
</p>
<p>
Flat discounts are deducted in the currency the user is being charged — not converted from <b>{plan?.currency ?? "USD"}</b>.
The Currency select below only shows available currencies for this plan.
Use <b>percent</b> for consistent savings across all currencies.
{" "}<a
href={`/admin/tiers/plans/${planId}/prices`}
className="inline-flex items-center gap-0.5 underline underline-offset-2 font-medium"
>
Manage prices <ExternalLink className="h-3 w-3" />
</a>
</p>
</div>
</div>
)}
{/* Existing rules */}
{promoRules.length > 0 ? (
<div className="space-y-2">
@@ -25,54 +25,7 @@ export default function PlanList() {
</div>
<div className="w-full">
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4 mb-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<Globe className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Localized prices are configured per plan
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Each plan can have currency-specific prices for international users (e.g. CNY, EUR, JPY). Select <b>"Localized Prices"</b>. Users without a localized price fall back to the plan's base price. If no localized price is set, the price will be displayed in <b>US Dollar (USD)</b>.
</p>
<div className="flex items-center gap-4 mt-2">
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Globe /> Localized Prices (per currency)
</Badge>
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<ShieldCheck /> Falls back to base price
</Badge>
</div>
</div>
</div>
</div>
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4 mb-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<CreditCard className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Payment Policies are configured per plan
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Each plan can have its own set of promo codes and refund window.
Open a plan's row actions and select <b>"Promo codes (flat or percent discount)"</b> to configure it.
</p>
<div className="flex items-center gap-4 mt-2">
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Tag /> Promo codes (flat or percent discount)
</Badge>
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<ShieldCheck /> Refund window (minutes / hours / days)
</Badge>
</div>
</div>
</div>
</div>
<TierPlansTable />
</div>
+533 -146
View File
@@ -1,18 +1,27 @@
import { useEffect, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, BookOpen, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
ShieldCheck, Plus, Trash2, Loader2, Receipt,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { useTiers } from "@/contexts/AdminTiersContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import PaymentsTable from "@/modules/admin/components/tiers/PaymentsTable";
const STATUS_BADGE = { true: "default", false: "secondary" };
// ─── Shared helpers ────────────────────────────────────────────────────────────
function InfoRow({ label, children }) {
return (
@@ -25,12 +34,15 @@ function InfoRow({ label, children }) {
);
}
function SectionCard({ icon: Icon, title, children }) {
function SectionCard({ icon: Icon, title, description, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
<div>
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5 ml-6">{description}</p>}
</div>
<Separator />
{children}
@@ -38,22 +50,13 @@ function SectionCard({ icon: Icon, title, children }) {
);
}
function LoadingSkeleton() {
return (
<div className="space-y-5">
<Skeleton className="h-8 w-64" />
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
function formatDuration(days, unit) {
if (!days) return `${days} days`;
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
const value = Math.round((days / multiplier) * 1000) / 1000;
const label = unit ?? 'day';
return `${value} ${label}${value !== 1 ? 's' : ''}`;
const label = unit ?? "day";
return `${value} ${label}${value !== 1 ? "s" : ""}`;
}
function formatCourseDuration(seconds = 0) {
@@ -65,17 +68,452 @@ function formatCourseDuration(seconds = 0) {
return `${m}m`;
}
export default function ViewPlan() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading } = useTiers();
// ─── Tab: Plan Details ─────────────────────────────────────────────────────────
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading }) {
const { fmtDateTime } = useDateFormat();
if (loading && !plan) {
return (
<div className="space-y-5">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
if (!plan) return <p className="text-sm text-muted-foreground">Plan not found.</p>;
return (
<div className="space-y-5">
<SectionCard icon={Tag} title="Plan Details">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{plan.label}</InfoRow>
<InfoRow label="Tier">
{(() => {
const { cls, label } = resolveTierBadge(plan.tier, tierMap);
return <Badge className={`${cls} mt-0.5`}>{label}</Badge>;
})()}
</InfoRow>
<InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
<InfoRow label="Price">{plan.currency} {Number(plan.price).toFixed(2)}</InfoRow>
<InfoRow label="Currency">{plan.currency}</InfoRow>
<InfoRow label="Status">
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</InfoRow>
</div>
{plan.description && (
<div className="flex flex-col gap-0.5 pt-1">
<span className="text-xs text-muted-foreground uppercase tracking-wide">Description</span>
<p className="text-sm">{plan.description}</p>
</div>
)}
</SectionCard>
<SectionCard icon={BookOpen} title="Assigned Courses">
{coursesLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedCourses.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No courses assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedCourses.map((course) => (
<div key={course.course_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
{(course.course_code || course.level) && (
<div className="flex items-center gap-2 mt-0.5">
{course.course_code && (
<span className="text-xs text-muted-foreground">{course.course_code}</span>
)}
{course.level && (
<Badge variant="outline" className="text-xs capitalize h-4 px-1.5">{course.level}</Badge>
)}
</div>
)}
</div>
</div>
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground flex items-center gap-1 shrink-0">
<Clock className="size-3" />
{formatCourseDuration(course.duration_seconds)}
</span>
)}
</div>
))}
</div>
)}
{!coursesLoading && (
<p className="text-xs text-muted-foreground">
{assignedCourses.length} course{assignedCourses.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard>
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At">{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}</InfoRow>
<InfoRow label="Updated At">{plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}</InfoRow>
</div>
</SectionCard>
</div>
);
}
// ─── Tab: Payment Policy ───────────────────────────────────────────────────────
const EMPTY_PROMO = { code: "", type: "flat", value: "", currency: "USD", max_discount: "", max_uses: "", expires_at: "", min_amount: "" };
const WINDOW_UNITS = ["minutes", "hours", "days"];
function PaymentPolicyTab({ planId, plan }) {
const [policyLoading, setPolicyLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [refundAllowed, setRefundAllowed] = useState(true);
const [refundWindowValue, setRefundWindowValue] = useState(5);
const [refundWindowUnit, setRefundWindowUnit] = useState("minutes");
const [refundReasonReqd, setRefundReasonReqd] = useState(false);
const [promoRules, setPromoRules] = useState([]);
const [addForm, setAddForm] = useState(EMPTY_PROMO);
const [showAdd, setShowAdd] = useState(false);
const [localizedPrices, setLocalizedPrices] = useState([]);
useEffect(() => {
setPolicyLoading(true);
api.get(`/admin/tier-policies/plans/${planId}/payment-policy`)
.then(({ data }) => {
const p = data.data;
if (p) {
const rp = p.refund_policy ?? {};
setRefundAllowed(rp.allowed ?? true);
setRefundWindowValue(rp.window_value ?? 5);
setRefundWindowUnit(rp.window_unit ?? "minutes");
setRefundReasonReqd(rp.reason_required ?? false);
setPromoRules(p.promo_rules ?? []);
}
})
.catch(() => {})
.finally(() => setPolicyLoading(false));
api.get(`/admin/tiers/${planId}/prices`)
.then(({ data }) => setLocalizedPrices(data.data ?? []))
.catch(() => {});
}, [planId]);
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/admin/tier-policies/plans/${planId}/payment-policy`, {
refund_policy: {
allowed: refundAllowed,
window_value: Number(refundWindowValue),
window_unit: refundWindowUnit,
reason_required: refundReasonReqd,
},
promo_rules: promoRules,
});
toast.success("Payment policy saved.");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save payment policy.");
} finally {
setSaving(false);
}
};
const handleAddPromo = () => {
const code = addForm.code.trim().toUpperCase();
if (!code) { toast.error("Code is required."); return; }
if (!addForm.value || Number(addForm.value) <= 0) { toast.error("Value must be greater than 0."); return; }
if (promoRules.some((r) => r.code.toUpperCase() === code)) { toast.error("A rule with this code already exists."); return; }
const rule = {
code,
type: addForm.type,
value: Number(addForm.value),
...(addForm.type === "flat" && addForm.currency ? { currency: addForm.currency.trim().toUpperCase() } : {}),
...(addForm.type === "percent" && addForm.max_discount ? { max_discount: Number(addForm.max_discount) } : {}),
...(addForm.max_uses ? { max_uses: Number(addForm.max_uses) } : {}),
...(addForm.expires_at ? { expires_at: addForm.expires_at } : {}),
...(addForm.min_amount ? { min_amount: Number(addForm.min_amount) } : {}),
};
setPromoRules((prev) => [...prev, rule]);
setAddForm(EMPTY_PROMO);
setShowAdd(false);
};
if (policyLoading) {
return (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
return (
<div className="space-y-5">
<SectionCard
icon={ShieldCheck}
title="Refund Policy"
description="Controls whether and how long after purchase a user can request a refund."
>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Allow Refunds</p>
<p className="text-xs text-muted-foreground">Users can request a refund within the window below.</p>
</div>
<Switch checked={refundAllowed} onCheckedChange={setRefundAllowed} />
</div>
{refundAllowed && (
<>
<div className="space-y-1.5">
<Label>Refund Window</Label>
<div className="flex gap-2">
<Input
type="number"
min={1}
className="w-28"
value={refundWindowValue}
onChange={(e) => setRefundWindowValue(e.target.value)}
placeholder="5"
/>
<Select value={refundWindowUnit} onValueChange={setRefundWindowUnit}>
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
{WINDOW_UNITS.map((u) => (
<SelectItem key={u} value={u} className="capitalize">{u}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Users have {refundWindowValue || "?"} {refundWindowUnit} from payment to request a refund.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Require Reason</p>
<p className="text-xs text-muted-foreground">User must provide a reason when requesting a refund.</p>
</div>
<Switch checked={refundReasonReqd} onCheckedChange={setRefundReasonReqd} />
</div>
</>
)}
</div>
</SectionCard>
<SectionCard
icon={Tag}
title="Promo Codes"
description="Define discount codes users can apply at checkout."
>
{promoRules.length > 0 ? (
<div className="space-y-2">
{promoRules.map((rule) => (
<div key={rule.code} className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<code className="text-sm font-semibold tracking-wide">{rule.code}</code>
<Badge variant="outline" className="text-xs capitalize shrink-0">{rule.type}</Badge>
<span className="text-sm text-muted-foreground shrink-0">
{rule.type === "flat"
? `${rule.currency ?? "USD"} ${Number(rule.value).toFixed(2)} off`
: `${rule.value}% off${rule.max_discount ? ` (max ${rule.max_discount})` : ""}`}
</span>
{rule.max_uses && (
<span className="text-xs text-muted-foreground shrink-0">· {rule.max_uses} uses max</span>
)}
{rule.expires_at && (
<span className="text-xs text-muted-foreground shrink-0">
· expires {new Date(rule.expires_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive shrink-0"
onClick={() => setPromoRules((prev) => prev.filter((r) => r.code !== rule.code))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Tag className="size-4 shrink-0" />
No promo codes configured for this plan.
</div>
)}
{showAdd ? (
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
<p className="text-sm font-medium">New Promo Code</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Code <span className="text-destructive">*</span></Label>
<Input
placeholder="e.g. SAVE10"
value={addForm.code}
onChange={(e) => setAddForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))}
/>
</div>
<div className="space-y-1.5">
<Label>Type <span className="text-destructive">*</span></Label>
<Select value={addForm.type} onValueChange={(v) => setAddForm((f) => ({ ...f, type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="flat">Flat (fixed amount off)</SelectItem>
<SelectItem value="percent">Percent (% off)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{addForm.type === "flat" ? "Amount Off" : "Percent Off"} <span className="text-destructive">*</span></Label>
<Input
type="number" step="0.01" min="0.01"
placeholder={addForm.type === "flat" ? "10.00" : "20"}
value={addForm.value}
onChange={(e) => setAddForm((f) => ({ ...f, value: e.target.value }))}
/>
</div>
{addForm.type === "flat" && (
<div className="space-y-1.5">
<Label>Currency</Label>
<Select value={addForm.currency} onValueChange={(v) => setAddForm((f) => ({ ...f, currency: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={plan?.currency ?? "USD"}>{plan?.currency ?? "USD"} — Base price</SelectItem>
{localizedPrices.map((p) => (
<SelectItem key={p.currency} value={p.currency}>{p.currency} — Localized price</SelectItem>
))}
</SelectContent>
</Select>
{localizedPrices.length === 0 && (
<p className="text-xs text-muted-foreground">No localized prices set — only base currency available.</p>
)}
</div>
)}
{addForm.type === "percent" && (
<div className="space-y-1.5">
<Label>Max Discount Cap</Label>
<Input
type="number" step="0.01" placeholder="50.00 (optional)"
value={addForm.max_discount}
onChange={(e) => setAddForm((f) => ({ ...f, max_discount: e.target.value }))}
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Max Uses</Label>
<Input
type="number" min="1" placeholder="Unlimited"
value={addForm.max_uses}
onChange={(e) => setAddForm((f) => ({ ...f, max_uses: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label>Expires At</Label>
<DateTimePicker
value={addForm.expires_at || null}
onChange={(iso) => setAddForm((f) => ({ ...f, expires_at: iso ?? "" }))}
placeholder="No expiry"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Minimum Purchase Amount</Label>
<Input
type="number" step="0.01" placeholder="No minimum"
value={addForm.min_amount}
onChange={(e) => setAddForm((f) => ({ ...f, min_amount: e.target.value }))}
/>
</div>
<div className="flex gap-2 justify-end pt-1">
<Button variant="outline" size="sm" onClick={() => { setShowAdd(false); setAddForm(EMPTY_PROMO); }}>
Cancel
</Button>
<Button size="sm" onClick={handleAddPromo}>
<Plus className="h-4 w-4 mr-1" /> Add Code
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={() => { setAddForm((f) => ({ ...f, currency: plan?.currency ?? "USD" })); setShowAdd(true); }}
>
<Plus className="h-4 w-4 mr-1" /> Add Promo Code
</Button>
)}
</SectionCard>
<div className="flex justify-end gap-3 pt-1">
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save Policy
</Button>
</div>
</div>
);
}
// ─── Tab: Payments ─────────────────────────────────────────────────────────────
function PaymentsTab({ planId }) {
const { fetchPayments } = useTiers();
useEffect(() => {
fetchPayments({ filters: [{ field: "plan_id", value: planId }] });
}, [planId]);
return <PaymentsTable planId={planId} />;
}
// ─── Tabs config ───────────────────────────────────────────────────────────────
const TABS = [
{ key: "details", label: "Plan Details", icon: CreditCard },
{ key: "policy", label: "Payment Policy", icon: ShieldCheck },
{ key: "payments", label: "Payments", icon: Receipt },
];
// ─── Page ──────────────────────────────────────────────────────────────────────
export default function ViewPlan() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading } = useTiers();
const [activeTab, setActiveTab] = useState("details");
const [tierCategories, setTierCategories] = useState([]);
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
const [assignedCourses, setAssignedCourses] = useState([]);
const [coursesLoading, setCoursesLoading] = useState(false);
const [assignedCourses, setAssignedCourses] = useState([]);
const [coursesLoading, setCoursesLoading] = useState(false);
useEffect(() => {
fetchPlan(planId);
@@ -88,30 +526,31 @@ export default function ViewPlan() {
}, [planId]);
return (
<section className="bg-muted/60 min-h-full">
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: plan?.label ?? `Plan #${planId}` },
]} />
</div>
<div className="flex items-start justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Plan Details</h1>
<p className="text-sm text-muted-foreground">View plan information.</p>
</div>
{/* ── Sticky header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<CreditCard className="h-5 w-5 text-muted-foreground" />
View Plan
</h1>
{plan && (
<p className="text-sm text-muted-foreground capitalize">
{plan.tier} — {plan.label}
</p>
)}
</div>
<div className="flex items-center gap-2">
{activeTab === "details" && (
<Button
variant="outline"
size="sm"
@@ -119,107 +558,55 @@ export default function ViewPlan() {
disabled={loading}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
Edit Plan
</Button>
</div>
)}
</div>
{loading && !plan ? (
<LoadingSkeleton />
) : !plan ? (
<p className="text-sm text-muted-foreground">Plan not found.</p>
) : (
<div className="space-y-5">
<SectionCard icon={Tag} title="Plan Details">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{plan.label}</InfoRow>
<InfoRow label="Tier">
{(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
</InfoRow>
<InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
<InfoRow label="Price">
{plan.currency} {Number(plan.price).toFixed(2)}
</InfoRow>
<InfoRow label="Currency">{plan.currency}</InfoRow>
<InfoRow label="Status">
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</InfoRow>
</div>
{plan.description && (
<div className="flex flex-col gap-0.5 pt-1">
<span className="text-xs text-muted-foreground uppercase tracking-wide">Description</span>
<p className="text-sm">{plan.description}</p>
</div>
)}
</SectionCard>
<SectionCard icon={BookOpen} title="Assigned Courses">
{coursesLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedCourses.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No courses assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedCourses.map((course) => (
<div key={course.course_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
{(course.course_code || course.level) && (
<div className="flex items-center gap-2 mt-0.5">
{course.course_code && (
<span className="text-xs text-muted-foreground">{course.course_code}</span>
)}
{course.level && (
<Badge variant="outline" className="text-xs capitalize h-4 px-1.5">{course.level}</Badge>
)}
</div>
)}
</div>
</div>
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground flex items-center gap-1 shrink-0">
<Clock className="size-3" />
{formatCourseDuration(course.duration_seconds)}
</span>
)}
</div>
))}
</div>
)}
{!coursesLoading && (
<p className="text-xs text-muted-foreground">
{assignedCourses.length} course{assignedCourses.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard>
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At">
{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}
</InfoRow>
<InfoRow label="Updated At">
{plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}
</InfoRow>
</div>
</SectionCard>
</div>
)}
{/* Underline tabs */}
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={[
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
</div>
</section>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
{activeTab === "payments" ? (
<div className="pb-16">
<PaymentsTab planId={planId} />
</div>
) : (
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{activeTab === "details" && (
<PlanDetailsTab
plan={plan}
loading={loading}
tierMap={tierMap}
assignedCourses={assignedCourses}
coursesLoading={coursesLoading}
/>
)}
{activeTab === "policy" && (
<PaymentPolicyTab planId={planId} plan={plan} />
)}
</div>
)}
</div>
</div>
);
}
}
+56 -3
View File
@@ -92,7 +92,6 @@ import ViewPayment from '../pages/tiers/ViewPayment';
import TierCategories from '../pages/tiers/TierCategories';
import ArchivedPlanList from '../pages/tiers/ArchivedPlanList';
import PaymentPolicy from '../pages/tiers/PaymentPolicy';
import LocalizedPrices from '../pages/tiers/LocalizedPrices';
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
@@ -103,6 +102,23 @@ import AddAdvertisement from '../pages/advertisements/AddAdvertisement'
import EditAdvertisement from '../pages/advertisements/EditAdvertisement'
import ViewAdvertisement from '../pages/advertisements/ViewAdvertisement'
// Achievements
import Achievements from '../pages/achievements/Achievements'
import { AddAchievement, EditAchievement } from '../pages/achievements/EditAchievement'
// Email Templates
import EmailTemplates from '../pages/email_templates/EmailTemplates'
import AddEmailTemplate from '../pages/email_templates/AddEmailTemplate'
import EditEmailTemplate from '../pages/email_templates/EditEmailTemplate'
import EmailBroadcasts from '../pages/email_templates/EmailBroadcasts'
// Notification Broadcasts
import NotificationBroadcastList from '../pages/notifications/NotificationBroadcastList'
import AddNotificationBroadcast from '../pages/notifications/AddNotificationBroadcast'
import EditNotificationBroadcast from '../pages/notifications/EditNotificationBroadcast'
import ViewNotificationBroadcast from '../pages/notifications/ViewNotificationBroadcast'
import NotificationSettings from '../pages/notifications/NotificationSettings'
// Activity
import ActivityFeed from '../pages/activity/ActivityFeed'
import UserActivityPage from '../pages/activity/UserActivityPage'
@@ -259,10 +275,8 @@ export const AdminRoutes = {
{ path: ':planId/view', element: <ViewPlan /> },
{ path: ':planId/edit', element: <EditPlan /> },
{ path: ':planId/payment-policy', element: <PaymentPolicy /> },
{ path: ':planId/prices', element: <LocalizedPrices /> },
]
},
{ path: 'prices', element: <LocalizedPrices /> },
{ path: 'system-badges', element: <SystemBadges /> },
{
path: 'categories',
@@ -301,6 +315,45 @@ export const AdminRoutes = {
]
},
// Achievements
{
path: 'achievements',
element: <Outlet />,
children: [
{ index: true, element: <Achievements /> },
{ path: 'add', element: <AddAchievement /> },
{ path: ':id/edit', element: <EditAchievement /> },
]
},
// Email Templates
{
path: 'email-templates',
element: <Outlet />,
children: [
{ index: true, element: <EmailTemplates /> },
{ path: 'add', element: <AddEmailTemplate /> },
{ path: ':id/edit', element: <EditEmailTemplate /> },
]
},
{ path: 'email-broadcasts', element: <EmailBroadcasts /> },
// Notifications
{
path: 'notifications',
element: <Outlet />,
children: [
{ index: true, element: <NotificationBroadcastList /> },
{ path: 'add', element: <AddNotificationBroadcast /> },
{ path: 'settings', element: <NotificationSettings /> },
{ path: ':broadcastId/view', element: <ViewNotificationBroadcast /> },
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
]
},
// Activity Feed
{ path: 'activity', element: <ActivityFeed /> },
@@ -1,10 +1,18 @@
import { Trophy, Clock } from "lucide-react";
import { Trophy, Clock, CheckCircle2 } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
/**
* Props:
* course — { title, ... } the completed course
* course — { title, pending_certificate, certificate, ... } the completed course
*/
const CourseCompleteBlock = ({ course }) => {
const { fmtDate } = useDateFormat();
const certificate = course?.certificate ?? null;
const pendingCert = course?.pending_certificate ?? null;
const isIssued = !!certificate;
const isPending = !isIssued && !!pendingCert;
return (
<div className="max-w-2xl mx-auto">
<div className="rounded-xl border bg-card p-6 space-y-6 text-center sm:p-10">
@@ -25,18 +33,33 @@ const CourseCompleteBlock = ({ course }) => {
You've passed all required units and the final assessment for this course.
</p>
</div>
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-left">
<Clock className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Certificate Pending</p>
<p className="text-xs text-muted-foreground">
Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications.
</p>
{isIssued ? (
<div className="flex items-start gap-2.5 rounded-lg border border-green-500/30 bg-green-500/5 px-4 py-3 text-left">
<CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-green-700 dark:text-green-400">Certificate Issued</p>
<p className="text-xs text-muted-foreground">
Issued on {fmtDate(certificate.issued_at)}. View and download it from your Certificates page.
</p>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-left">
<Clock className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Certificate Pending</p>
<p className="text-xs text-muted-foreground">
{isPending
? `Certificates are issued automatically every hour. Yours will be ready by ${fmtDate(pendingCert.issue_at)} — check your notifications.`
: "Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications."}
</p>
</div>
</div>
)}
</div>
</div>
);
};
export default CourseCompleteBlock;
export default CourseCompleteBlock;
@@ -179,18 +179,13 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
<div>
<h1
onClick={(e) => {
if (!taskId || !groupId || !taskListId) return;
if (!info?.course_id) return;
e.stopPropagation();
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { course: { id: course.id, reference_id: course.reference_id, title: course.title } } }
);
navigate(`/course/${info.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
taskId
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
}`}
className="text-base font-semibold leading-snug line-clamp-2 transition-colors text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
>
{course.title}
</h1>
@@ -242,18 +237,12 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
<Button
onClick={() => {
if (!info?.course_id) return;
if (taskId && groupId && taskListId) {
// Task context — read inside ViewRequirement
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { course: { id: selected.id, reference_id: selected.reference_id, title: selected.title } } }
);
} else {
// No task context — fall back to standalone course reader
navigate(`/course/${info.course_id}/unit`, {
state: allRead ? { seekFirstIncomplete: true } : {},
});
}
navigate(`/course/${info.course_id}/unit`, {
state: {
...(allRead ? { seekFirstIncomplete: true } : {}),
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
},
});
}}
disabled={done || !info?.course_id}
>
@@ -147,10 +147,16 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
return (
<div
key={lesson.id}
onClick={() => !isFetching && navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { lesson } },
)}
onClick={() => {
if (isFetching || !info?.unit?.course?.course_id) return;
navigate(`/course/${info.unit.course.course_id}/unit`, {
state: {
lessonId: info.lesson_id,
unitId: info.unit.unit_id,
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
},
});
}}
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-96 shrink-0 ${
isFetching
? 'opacity-60 cursor-wait'
@@ -194,18 +194,13 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<div>
<h1
onClick={(e) => {
if (!taskId || !groupId || !taskListId) return;
if (!info?.course?.course_id) return;
e.stopPropagation();
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { unit } }
);
navigate(`/course/${info.course.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
taskId
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
}`}
className="text-base font-semibold leading-snug line-clamp-2 transition-colors text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
>
{unit.title}
</h1>
@@ -251,10 +246,13 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<>
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
<Button
onClick={() => navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { unit: selected } },
)}
onClick={() => {
const info = details[selected?.reference_id];
if (!info?.course?.course_id) return;
navigate(`/course/${info.course.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
disabled={
getProgress(selected ?? {}) >= 100 ||
locked[selected?.reference_id] ||
+106 -60
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KeyRound, CreditCard, Mail, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2, Globe } from "lucide-react";
import { KeyRound, CreditCard, Mail, Megaphone, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -20,12 +20,10 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAuth } from "@/contexts/AuthContext";
import { useProfile } from "@/contexts/ProfileProvider";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
import api from "@/utils/api.util";
import { toast } from "sonner";
@@ -285,74 +283,122 @@ function NewsletterSection() {
);
}
// ─── Currency Preference ─────────────────────────────────────────────────────
// ─── Advertisements ───────────────────────────────────────────────────────────
function CurrencySection() {
const { profile, getProfile } = useProfile();
const { setCurrency } = useCurrencyPreference();
const [currencies, setCurrencies] = useState([]);
const [localValue, setLocalValue] = useState("USD");
const [saving, setSaving] = useState(false);
const AD_OPTIONS = [
{
key: "show_popup_ads",
label: "Popup ads",
description: "Show promotional popups when you open pages like the dashboard.",
},
{
key: "show_other_ads",
label: "Other ads",
description: "Show banner, hero, and sidebar advertisements across the site.",
},
];
function AdvertisementsSection() {
const { profile, getProfile, updateProfile, profileLoading } = useProfile();
const [confirmPopupOff, setConfirmPopupOff] = useState(false);
useEffect(() => {
getProfile();
api.get("/client/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
// Sync local value when profile loads or changes
useEffect(() => {
if (profile?.preferred_currency) setLocalValue(profile.preferred_currency);
}, [profile?.preferred_currency]);
const showPopupAds = profile?.personal_info?.show_popup_ads ?? true;
const showOtherAds = profile?.personal_info?.show_other_ads ?? true;
// No separate stored flag — "hidden" just means both underlying toggles are off,
// so it can never drift out of sync with them.
const hideAllAds = !showPopupAds && !showOtherAds;
const savedValue = profile?.preferred_currency ?? "USD";
const isDirty = localValue !== savedValue;
const handleToggle = async (key, value) => {
// Turning popup ads off also turns off other ads — confirm first since
// it's a bigger change than the switch being flipped suggests.
if (key === "show_popup_ads" && value === false) {
setConfirmPopupOff(true);
return;
}
const result = await updateProfile({ [key]: value });
if (result?.success) {
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
}
};
const handleSave = async () => {
setSaving(true);
try {
await api.patch("/client/profile/currency", { currency: localValue });
setCurrency(localValue);
toast.success("Currency preference saved.");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save preference.");
setLocalValue(savedValue); // revert on error
} finally {
setSaving(false);
const confirmTurnOffPopupAndOther = async () => {
setConfirmPopupOff(false);
const result = await updateProfile({ show_popup_ads: false, show_other_ads: false });
if (result?.success) {
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
}
};
const handleHideAllToggle = async (hide) => {
const result = await updateProfile({ show_popup_ads: !hide, show_other_ads: !hide });
if (result?.success) {
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
}
};
return (
<div className="space-y-3">
<div className="space-y-1.5 max-w-xs">
<Label>Preferred currency</Label>
{!profile ? (
<Skeleton className="h-9 w-full" />
) : (
<Select value={localValue} onValueChange={setLocalValue} disabled={saving}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code} — {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="text-xs text-muted-foreground">
Plans without localized prices always display in USD regardless of this setting.
</p>
<>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Hide all ads</p>
<p className="text-xs text-muted-foreground">
Turn off every advertisement across the platform, popups included.
</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch checked={hideAllAds} onCheckedChange={handleHideAllToggle} />
)}
</div>
<Separator className="mt-4" />
</div>
{AD_OPTIONS.map((opt, i) => (
<div key={opt.key}>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">{opt.label}</p>
<p className="text-xs text-muted-foreground">{opt.description}</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch
checked={profile?.personal_info?.[opt.key] ?? true}
onCheckedChange={(v) => handleToggle(opt.key, v)}
/>
)}
</div>
{i < AD_OPTIONS.length - 1 && <Separator className="mt-4" />}
</div>
))}
</div>
{isDirty && (
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
)}
</div>
<AlertDialog open={confirmPopupOff} onOpenChange={setConfirmPopupOff}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Turn off popup ads?</AlertDialogTitle>
<AlertDialogDescription>
This will also turn off Other ads (banner, hero, and sidebar advertisements).
You can turn either back on here anytime.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmTurnOffPopupAndOther}>
Turn off both
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -446,8 +492,8 @@ export default function AccountSettings() {
<NewsletterSection />
</Section>
<Section icon={Globe} title="Currency Preference" description="Set the currency used to display plan prices across the platform.">
<CurrencySection />
<Section icon={Megaphone} title="Advertisements" description="Control which advertisements you see across the platform.">
<AdvertisementsSection />
</Section>
<Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">

Some files were not shown because too many files have changed in this diff Show More