mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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." },
|
||||
};
|
||||
@@ -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])
|
||||
);
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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])
|
||||
);
|
||||
@@ -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;
|
||||
}, {})
|
||||
);
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)} · {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} · 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] ||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user