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 { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext';
|
import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext';
|
||||||
import { CurrencyPreferenceProvider } from './contexts/CurrencyPreferenceContext';
|
|
||||||
import { Helmet, HelmetProvider } from "react-helmet-async";
|
import { Helmet, HelmetProvider } from "react-helmet-async";
|
||||||
import { TooltipProvider } from './components/ui/tooltip';
|
import { TooltipProvider } from './components/ui/tooltip';
|
||||||
import { setAuthInterceptor } from './utils/api.util';
|
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:description" content="This is still in development phase. Come back soon." />
|
||||||
<meta name="twitter:image" content="https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC" />
|
<meta name="twitter:image" content="https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC" />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||||
<DateTimePreferenceProvider>
|
<DateTimePreferenceProvider>
|
||||||
<CurrencyPreferenceProvider>
|
<TooltipProvider delayDuration={300}>
|
||||||
<TooltipProvider delayDuration={300}>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<AppWithAuth />
|
||||||
<AppWithAuth />
|
</AuthProvider>
|
||||||
</AuthProvider>
|
</TooltipProvider>
|
||||||
</TooltipProvider>
|
|
||||||
</CurrencyPreferenceProvider>
|
|
||||||
</DateTimePreferenceProvider>
|
</DateTimePreferenceProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Spinner } from "@/components/ui/spinner";
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
import api from "@/utils/api.util";
|
|
||||||
|
|
||||||
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
|
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
|
||||||
const DEBOUNCE_MS = 400;
|
const DEBOUNCE_MS = 400;
|
||||||
@@ -73,23 +72,34 @@ function EmptyState({ fileType }) {
|
|||||||
// ─── Main Sheet ───────────────────────────────────────────────────────────────
|
// ─── Main Sheet ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
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 [search, setSearch] = useState("");
|
||||||
const [activeExts, setActiveExts] = useState(new Set());
|
const [activeExts, setActiveExts] = useState(new Set());
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
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 debounceRef = useRef(null);
|
||||||
|
const isFirstSearchRun = useRef(true);
|
||||||
const LIMIT = 12;
|
const LIMIT = 12;
|
||||||
|
|
||||||
const extOptions = EXT_OPTIONS[fileType] ?? [];
|
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 ──────────────────────────────────────────────────
|
// ── Build and fire fetch ──────────────────────────────────────────────────
|
||||||
const doFetch = useCallback((searchVal, extSet, pg) => {
|
const doFetch = useCallback((searchVal, extSet, pg) => {
|
||||||
const filters = [
|
const filters = [
|
||||||
@@ -100,8 +110,19 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
|||||||
fetchAssets({ page: pg, limit: LIMIT, filters });
|
fetchAssets({ page: pg, limit: LIMIT, filters });
|
||||||
}, [fileType, fetchAssets]);
|
}, [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(() => {
|
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;
|
if (!open) return;
|
||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
@@ -109,51 +130,26 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
|||||||
doFetch(search, activeExts, 1);
|
doFetch(search, activeExts, 1);
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
return () => clearTimeout(debounceRef.current);
|
return () => clearTimeout(debounceRef.current);
|
||||||
}, [search, open]);
|
}, [search]);
|
||||||
|
|
||||||
// ── Immediate fetch on ext filter or page change ──────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
doFetch(search, activeExts, page);
|
|
||||||
}, [activeExts, page, open]);
|
|
||||||
|
|
||||||
// ── Batch token fetch after assets load ───────────────────────────────────
|
// ── Batch token fetch after assets load ───────────────────────────────────
|
||||||
// One request for all S3 assets on the current page instead of N per-card requests.
|
// One request for all S3 assets on the current page instead of N per-card
|
||||||
// This eliminates the thundering-herd / auth-refresh race that caused some cards to
|
// requests. getMediaTokens (AdminAssetsContext) already skips any asset_id
|
||||||
// silently show "No preview" after a page reload (multiple 401s queuing simultaneously
|
// whose cached token is still valid, so reopening the sheet within the
|
||||||
// while the interceptor refreshes, some dropping if cancelled mid-flight).
|
// token's ~30min TTL issues no request at all for previously-seen assets.
|
||||||
useEffect(() => {
|
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
|
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);
|
.map((a) => a.asset_id);
|
||||||
|
|
||||||
if (!s3Ids.length) return;
|
if (!s3Ids.length) return;
|
||||||
|
|
||||||
let cancelled = false;
|
getMediaTokens(s3Ids).catch((err) => {
|
||||||
api.post("/admin/media/tokens", { asset_ids: s3Ids })
|
console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
|
||||||
.then(({ data }) => {
|
});
|
||||||
if (cancelled) return;
|
}, [assets, open, getMediaTokens]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// ── Reset on close ────────────────────────────────────────────────────────
|
// ── Reset on close ────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -163,7 +159,6 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setFilterOpen(false);
|
setFilterOpen(false);
|
||||||
setStreamUrls({});
|
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -187,7 +182,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
|||||||
// (e.g. badge image picker) can use the authenticated URL directly
|
// (e.g. badge image picker) can use the authenticated URL directly
|
||||||
// rather than falling back to asset.file_url which is a private CDN
|
// rather than falling back to asset.file_url which is a private CDN
|
||||||
// key that the browser cannot load without S3 credentials.
|
// 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.thumbnail_url
|
||||||
?? asset.file_url
|
?? asset.file_url
|
||||||
?? null;
|
?? null;
|
||||||
@@ -304,7 +299,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
|||||||
<AssetCard
|
<AssetCard
|
||||||
key={asset.asset_id}
|
key={asset.asset_id}
|
||||||
asset={asset}
|
asset={asset}
|
||||||
streamSrc={streamUrls[String(asset.asset_id)] ?? null}
|
streamSrc={resolveStreamSrc(asset.asset_id)}
|
||||||
selected={selected === asset.asset_id}
|
selected={selected === asset.asset_id}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||||
import api from "@/utils/api.util";
|
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(/\/$/, "");
|
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>}
|
{!readOnly && <Label>Audio</Label>}
|
||||||
|
|
||||||
{isS3 && tokenLoading ? (
|
{isS3 && tokenLoading ? (
|
||||||
<div className="w-full max-w-lg rounded-xl border border-border bg-card flex items-center justify-center h-28">
|
<MediaFallback className="w-full max-w-lg h-28 rounded-xl border border-border" />
|
||||||
<div className="w-5 h-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : src ? (
|
) : src ? (
|
||||||
<div className="w-full max-w-lg rounded-xl overflow-hidden border border-border bg-card text-card-foreground shadow-sm">
|
<div className="w-full max-w-lg rounded-xl overflow-hidden border border-border bg-card text-card-foreground shadow-sm">
|
||||||
<audio
|
<audio
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
|
|
||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone } from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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).
|
// 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 = {
|
const SIZE_HEIGHT = {
|
||||||
sm: "h-20",
|
sm: "h-[80px]",
|
||||||
md: "h-32",
|
md: "h-[140px]",
|
||||||
lg: "h-48",
|
lg: "h-[clamp(220px,26vw,320px)]",
|
||||||
|
xl: "h-[clamp(320px,34vw,460px)]",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Banner ───────────────────────────────────────────────────────────────────
|
// ── Banner ───────────────────────────────────────────────────────────────────
|
||||||
@@ -19,13 +23,13 @@ const SIZE_HEIGHT = {
|
|||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* ad — advertisement object { headline, ctas, image, image_url, advertisement_id }
|
* 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.
|
* onCtaClick — (ad, cta) => void, called on click. cta may be undefined if the ad has none.
|
||||||
*/
|
*/
|
||||||
export function Banner({ ad, size, onCtaClick }) {
|
export function Banner({ ad, size, onCtaClick }) {
|
||||||
if (!ad) return null;
|
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 ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||||
const resolvedSize = ad.size || size || "md";
|
const resolvedSize = ad.size || size || "md";
|
||||||
const heightClass = SIZE_HEIGHT[resolvedSize] ?? SIZE_HEIGHT.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}`}
|
className={`relative w-full rounded-lg bg-muted overflow-hidden flex items-center justify-center text-left ${heightClass}`}
|
||||||
>
|
>
|
||||||
{imageSrc ? (
|
{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 && (
|
{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>
|
<p className="text-white font-medium text-sm sm:text-base">{ad.headline}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Megaphone } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
|
||||||
// ── Hero ─────────────────────────────────────────────────────────────────────
|
// ── Hero ─────────────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
@@ -18,24 +19,24 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
export function Hero({ ad, onCtaClick }) {
|
export function Hero({ ad, onCtaClick }) {
|
||||||
if (!ad) return null;
|
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 ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-3">
|
||||||
{ad.badge_label && (
|
{ad.badge_label && (
|
||||||
<Badge variant="outline">
|
<Badge variant="outline" className="pointer-events-none select-none">
|
||||||
<Megaphone /> {ad.badge_label}
|
<Megaphone /> {ad.badge_label}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{ad.headline && (
|
{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}
|
{ad.headline}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ad.description && (
|
{ad.description && (
|
||||||
<p className="max-w-lg">
|
<p className="max-w-lg pointer-events-none select-none">
|
||||||
{ad.description}
|
{ad.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -53,7 +54,7 @@ export function Hero({ ad, onCtaClick }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 ? (
|
{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" />
|
||||||
) : (
|
) : (
|
||||||
@@ -68,7 +69,7 @@ export function Hero({ ad, onCtaClick }) {
|
|||||||
|
|
||||||
export function HeroSkeleton() {
|
export function HeroSkeleton() {
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-3 w-full max-w-lg">
|
||||||
<Skeleton className="h-6 w-32 rounded-full" />
|
<Skeleton className="h-6 w-32 rounded-full" />
|
||||||
<Skeleton className="h-10 w-3/4" />
|
<Skeleton className="h-10 w-3/4" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
|
||||||
// ── Popup ────────────────────────────────────────────────────────────────────
|
// ── Popup ────────────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
@@ -11,17 +12,23 @@ import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
|||||||
* to true once an active popup ad resolves from the API).
|
* to true once an active popup ad resolves from the API).
|
||||||
*
|
*
|
||||||
* Props:
|
* Props:
|
||||||
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
|
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
|
||||||
* open — boolean, modal visibility
|
* open — boolean, modal visibility
|
||||||
* onOpenChange — (open: boolean) => void
|
* onOpenChange — (open: boolean) => void
|
||||||
* onCtaClick — (ad, cta) => void, called when a footer CTA button is clicked
|
* 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;
|
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 ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||||
|
|
||||||
|
const handleDismissForever = () => {
|
||||||
|
onOpenChange?.(false);
|
||||||
|
onDismissForever?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -45,10 +52,20 @@ export function Popup({ ad, open, onOpenChange, onCtaClick }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{imageSrc && (
|
{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" />
|
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||||
</div>
|
</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>
|
</ResponsiveModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
|
||||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
@@ -18,7 +19,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
export function Sidebar({ ad, onCtaClick }) {
|
export function Sidebar({ ad, onCtaClick }) {
|
||||||
if (!ad) return null;
|
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 ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||||
const primaryCta = ctas[0];
|
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"
|
className="w-full max-w-xs rounded-lg border bg-card overflow-hidden flex flex-col cursor-pointer"
|
||||||
onClick={handleCardClick}
|
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 ? (
|
{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" />
|
||||||
) : (
|
) : (
|
||||||
@@ -41,8 +42,8 @@ export function Sidebar({ ad, onCtaClick }) {
|
|||||||
|
|
||||||
{(ad.headline || ad.description || primaryCta) && (
|
{(ad.headline || ad.description || primaryCta) && (
|
||||||
<div className="p-3 flex flex-col gap-1.5">
|
<div className="p-3 flex flex-col gap-1.5">
|
||||||
{ad.headline && <p className="text-sm font-medium leading-snug">{ad.headline}</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">{ad.description}</p>}
|
{ad.description && <p className="text-xs text-muted-foreground line-clamp-2 pointer-events-none select-none">{ad.description}</p>}
|
||||||
{primaryCta && (
|
{primaryCta && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef, useState, useEffect, useCallback } from "react";
|
import { useRef, useState, useEffect, useCallback } from "react";
|
||||||
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
|
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -172,11 +173,7 @@ export function AudioBlock({ content }) {
|
|||||||
// ── States ────────────────────────────────────────────────────────────────
|
// ── States ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (fetchLoading) {
|
if (fetchLoading) {
|
||||||
return (
|
return <MediaFallback className="w-full h-28 rounded-xl border border-border" />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchError) {
|
if (fetchError) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -426,11 +427,7 @@ export function VideoBlock({ content }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fetchLoading) {
|
if (fetchLoading) {
|
||||||
return (
|
return <MediaFallback className="aspect-video rounded-lg" />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fetchError || !blobUrl) {
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { Bell, Trophy, BookOpen, Star, CheckCircle, Copy, Check, Megaphone } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { 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() {
|
export default function ClientNotificationBell() {
|
||||||
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
|
const { unseenCount } = useClientNotifications();
|
||||||
useClientNotifications();
|
const navigate = useNavigate();
|
||||||
const { fmtDateTime } = useDateFormat();
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState(null);
|
return (
|
||||||
const [copiedCode, setCopiedCode] = useState(false);
|
<Button
|
||||||
|
variant="outline"
|
||||||
async function handleCopyCode(code) {
|
size="icon"
|
||||||
await navigator.clipboard.writeText(code);
|
className="relative"
|
||||||
setCopiedCode(true);
|
onClick={() => navigate("/notifications")}
|
||||||
setTimeout(() => setCopiedCode(false), 2000);
|
>
|
||||||
}
|
<Bell className="h-4 w-4" />
|
||||||
|
{unseenCount > 0 && (
|
||||||
function handleOpen(open) {
|
<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">
|
||||||
if (open) fetchNotifications();
|
{unseenCount > 99 ? "99+" : unseenCount}
|
||||||
}
|
</span>
|
||||||
|
)}
|
||||||
function handleClickNotification(n) {
|
</Button>
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// ─── components/ArchiveDialog.jsx ────────────────────────────────────────────
|
// ─── components/ArchiveDialog.jsx ────────────────────────────────────────────
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic archive dialog — works for any entity (users, groups, etc.)
|
* 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} ... />
|
* Single: <ArchiveDialog entity={rowObject} getName={(r) => r.name} ... />
|
||||||
* Bulk: <ArchiveDialog ids={[1, 2, 3]} entityLabel="Group" ... />
|
* Bulk: <ArchiveDialog ids={[1, 2, 3]} entityLabel="Group" ... />
|
||||||
*
|
*
|
||||||
* @param {Function} onArchive (id | { ids }) => Promise — called with single id or { ids }
|
* @param {Function} onArchive (id | { ids }) => Promise — called with single id or { ids }
|
||||||
* @param {Function} getName (entity) => string — how to display the entity name
|
* @param {Function} getName (entity) => string — how to display the entity name
|
||||||
* @param {string} entityLabel e.g. "User", "Group"
|
* @param {string} entityLabel e.g. "User", "Group"
|
||||||
* @param {boolean} loading from whichever context the parent uses
|
* @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({
|
export function ArchiveDialog({
|
||||||
open,
|
open,
|
||||||
@@ -32,14 +38,32 @@ export function ArchiveDialog({
|
|||||||
onArchive,
|
onArchive,
|
||||||
loading,
|
loading,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
onImpactCheck,
|
||||||
}) {
|
}) {
|
||||||
const isBulk = Array.isArray(ids) && ids.length > 0;
|
const isBulk = Array.isArray(ids) && ids.length > 0;
|
||||||
const count = isBulk ? ids.length : 1;
|
const count = isBulk ? ids.length : 1;
|
||||||
|
|
||||||
|
const [impactLoading, setImpactLoading] = useState(false);
|
||||||
|
const [impacts, setImpacts] = useState([]);
|
||||||
|
|
||||||
const displayName = isBulk
|
const displayName = isBulk
|
||||||
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
|
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
|
||||||
: (getName?.(entity) ?? entity?.name ?? entity?.email ?? "this item");
|
: (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 handleArchive = async () => {
|
||||||
const res = isBulk
|
const res = isBulk
|
||||||
? await onArchive({ ids })
|
? await onArchive({ ids })
|
||||||
@@ -51,6 +75,8 @@ export function ArchiveDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasImpact = impacts.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -58,19 +84,48 @@ export function ArchiveDialog({
|
|||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
Archive {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
|
Archive {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to archive{" "}
|
{impactLoading ? (
|
||||||
<span className="font-medium text-foreground">{displayName}</span>?{" "}
|
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
|
||||||
{isBulk
|
<Spinner className="size-4" /> Checking impact…
|
||||||
? "They will be deactivated and lose access immediately."
|
</div>
|
||||||
: "This will deactivate the record immediately."}
|
) : (
|
||||||
</AlertDialogDescription>
|
<>
|
||||||
|
<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>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={loading || impactLoading}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleArchive}
|
onClick={handleArchive}
|
||||||
disabled={loading}
|
disabled={loading || impactLoading}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{loading && <Spinner className="size-4 mr-2" />}
|
{loading && <Spinner className="size-4 mr-2" />}
|
||||||
@@ -80,4 +135,4 @@ export function ArchiveDialog({
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => onClick(value)}
|
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
|
${active
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40'
|
: '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 api from "@/utils/api.util";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -20,6 +20,16 @@ const PAGINATION_INIT = {
|
|||||||
hasNextPage: false,
|
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 }) {
|
export function AssetsProvider({ children }) {
|
||||||
const [assets, setAssets] = useState([]);
|
const [assets, setAssets] = useState([]);
|
||||||
const [attributes, setAttributes] = useState([]);
|
const [attributes, setAttributes] = useState([]);
|
||||||
@@ -27,6 +37,32 @@ export function AssetsProvider({ children }) {
|
|||||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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) => {
|
const request = useCallback(async (fn) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -41,9 +77,22 @@ export function AssetsProvider({ children }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─── GET /api/admin/assets ────────────────────────────────────────────────
|
// ─── 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(
|
const fetchAssets = useCallback(
|
||||||
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
({ page = 1, limit = 10, filters = [], sort = [], force = false } = {}) => {
|
||||||
request(async () => {
|
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", {
|
const { data } = await api.get("/admin/assets", {
|
||||||
params: {
|
params: {
|
||||||
page, limit,
|
page, limit,
|
||||||
@@ -57,12 +106,53 @@ export function AssetsProvider({ children }) {
|
|||||||
setAssets(result?.data ?? []);
|
setAssets(result?.data ?? []);
|
||||||
setPagination(result?.pagination ?? PAGINATION_INIT);
|
setPagination(result?.pagination ?? PAGINATION_INIT);
|
||||||
setAttributes(result.attributes);
|
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;
|
return data.data;
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
[request]
|
[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 ───────────────────────────────────────
|
// ─── GET /api/admin/assets/:assetId ───────────────────────────────────────
|
||||||
const fetchAsset = useCallback(
|
const fetchAsset = useCallback(
|
||||||
(assetId) =>
|
(assetId) =>
|
||||||
@@ -110,6 +200,7 @@ export function AssetsProvider({ children }) {
|
|||||||
const asset = res.data?.data?.data ?? null;
|
const asset = res.data?.data?.data ?? null;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
setAssets((prev) => [asset, ...prev]);
|
setAssets((prev) => [asset, ...prev]);
|
||||||
|
invalidateListCache();
|
||||||
toast.success("Asset uploaded successfully.");
|
toast.success("Asset uploaded successfully.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -135,6 +226,7 @@ export function AssetsProvider({ children }) {
|
|||||||
if (asset) {
|
if (asset) {
|
||||||
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
|
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
|
||||||
setSelectedAsset(asset);
|
setSelectedAsset(asset);
|
||||||
|
invalidateListCache();
|
||||||
toast.success("Asset updated successfully.");
|
toast.success("Asset updated successfully.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -151,6 +243,7 @@ export function AssetsProvider({ children }) {
|
|||||||
});
|
});
|
||||||
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
||||||
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
||||||
|
invalidateListCache();
|
||||||
toast.success("Asset archived.");
|
toast.success("Asset archived.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -165,6 +258,7 @@ export function AssetsProvider({ children }) {
|
|||||||
data: { ids, deletedBy },
|
data: { ids, deletedBy },
|
||||||
});
|
});
|
||||||
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
||||||
|
invalidateListCache();
|
||||||
toast.success(`${ids.length} asset(s) archived.`);
|
toast.success(`${ids.length} asset(s) archived.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -179,6 +273,7 @@ export function AssetsProvider({ children }) {
|
|||||||
const asset = res.data?.data?.data ?? null;
|
const asset = res.data?.data?.data ?? null;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
||||||
|
invalidateListCache();
|
||||||
toast.success("Asset restored.");
|
toast.success("Asset restored.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -192,6 +287,7 @@ export function AssetsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch("/admin/assets/bulk-restore", { ids });
|
const res = await api.patch("/admin/assets/bulk-restore", { ids });
|
||||||
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
||||||
|
invalidateListCache();
|
||||||
toast.success(`${ids.length} asset(s) restored.`);
|
toast.success(`${ids.length} asset(s) restored.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -215,6 +311,8 @@ export function AssetsProvider({ children }) {
|
|||||||
pagination,
|
pagination,
|
||||||
selectedAsset,
|
selectedAsset,
|
||||||
loading,
|
loading,
|
||||||
|
mediaTokens,
|
||||||
|
getMediaTokens,
|
||||||
setPagination,
|
setPagination,
|
||||||
setSelectedAsset,
|
setSelectedAsset,
|
||||||
fetchAssets,
|
fetchAssets,
|
||||||
|
|||||||
@@ -230,8 +230,22 @@ export function CoursesProvider({ children }) {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
const fetchUnits = useCallback(
|
const fetchUnits = useCallback(
|
||||||
(courseId, params = {}) =>
|
(courseId, params = {}) => {
|
||||||
paginatedGet(`${BASE}/${courseId}/units`, setUnits, 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],
|
[paginatedGet],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -470,6 +484,18 @@ export function CoursesProvider({ children }) {
|
|||||||
[request],
|
[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(
|
const deleteQuizQuestion = useCallback(
|
||||||
(courseId, unitId, quizId, questionId, deletedBy) =>
|
(courseId, unitId, quizId, questionId, deletedBy) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -796,6 +822,18 @@ export function CoursesProvider({ children }) {
|
|||||||
[request],
|
[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(
|
const deleteAssessmentQuestion = useCallback(
|
||||||
(courseId, assessmentId, questionId, deletedBy) =>
|
(courseId, assessmentId, questionId, deletedBy) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -1061,6 +1099,7 @@ export function CoursesProvider({ children }) {
|
|||||||
updateQuizQuestion,
|
updateQuizQuestion,
|
||||||
deleteQuizQuestion,
|
deleteQuizQuestion,
|
||||||
bulkArchiveQuizQuestions,
|
bulkArchiveQuizQuestions,
|
||||||
|
bulkSyncQuizQuestions,
|
||||||
|
|
||||||
// ── quiz question archives & restore ───────────────────────────────────
|
// ── quiz question archives & restore ───────────────────────────────────
|
||||||
fetchArchivedQuizQuestion,
|
fetchArchivedQuizQuestion,
|
||||||
@@ -1107,6 +1146,7 @@ export function CoursesProvider({ children }) {
|
|||||||
updateAssessmentQuestion,
|
updateAssessmentQuestion,
|
||||||
deleteAssessmentQuestion,
|
deleteAssessmentQuestion,
|
||||||
bulkArchiveAssessmentQuestions,
|
bulkArchiveAssessmentQuestions,
|
||||||
|
bulkSyncAssessmentQuestions,
|
||||||
|
|
||||||
// ── assessment question archives & restore ─────────────────────────────
|
// ── assessment question archives & restore ─────────────────────────────
|
||||||
fetchArchivedAssessmentQuestion,
|
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 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);
|
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() {
|
export function useClientAdvertisements() {
|
||||||
const ctx = useContext(ClientAdvertisementsContext);
|
const ctx = useContext(ClientAdvertisementsContext);
|
||||||
if (!ctx) throw new Error("useClientAdvertisements must be used within a ClientAdvertisementsProvider");
|
if (!ctx) throw new Error("useClientAdvertisements must be used within a ClientAdvertisementsProvider");
|
||||||
@@ -10,28 +28,104 @@ export function useClientAdvertisements() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClientAdvertisementsProvider({ children }) {
|
export function ClientAdvertisementsProvider({ children }) {
|
||||||
// Keyed by type so hero + popup (or any combo) can be fetched independently
|
const navigate = useNavigate();
|
||||||
// without clobbering each other: { hero: {...}, popup: {...} }
|
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 [advertisements, setAdvertisements] = useState({});
|
||||||
const [loading, setLoading] = 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(
|
const getActiveAdvertisement = useCallback(
|
||||||
async (type) => {
|
async (placement) => {
|
||||||
setLoading((prev) => ({ ...prev, [type]: true }));
|
setLoading((prev) => ({ ...prev, [placement]: true }));
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get("/client/advertisements/active", { params: { type } });
|
const [currentProfile, { data }] = await Promise.all([
|
||||||
const ad = data?.data?.data ?? null;
|
ensureProfile(),
|
||||||
setAdvertisements((prev) => ({ ...prev, [type]: ad }));
|
api.get("/client/advertisements/active", { params: { placement } }),
|
||||||
|
]);
|
||||||
|
const ad = resolveVisibility(currentProfile, data?.data?.data ?? null);
|
||||||
|
setAdvertisements((prev) => ({ ...prev, [placement]: ad }));
|
||||||
return ad;
|
return ad;
|
||||||
} catch {
|
} catch {
|
||||||
setAdvertisements((prev) => ({ ...prev, [type]: null }));
|
setAdvertisements((prev) => ({ ...prev, [placement]: null }));
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} 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 ───────────────
|
// ─── 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 (
|
return (
|
||||||
<ClientAdvertisementsContext.Provider value={{
|
<ClientAdvertisementsContext.Provider value={{
|
||||||
advertisements,
|
advertisements,
|
||||||
loading,
|
loading,
|
||||||
getActiveAdvertisement,
|
getActiveAdvertisement,
|
||||||
|
getActiveAdvertisements,
|
||||||
trackClick,
|
trackClick,
|
||||||
|
handleAdCtaClick,
|
||||||
|
dismissPopupForever,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{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>
|
</ClientAdvertisementsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ export function useClientNotifications() {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGINATION = { page: 1, limit: 10, pages: 1, total: 0 };
|
||||||
|
|
||||||
export function ClientNotificationProvider({ children }) {
|
export function ClientNotificationProvider({ children }) {
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [unseenCount, setUnseenCount] = useState(0);
|
const [unseenCount, setUnseenCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
const pollSpeedRef = useRef(POLL_INTERVAL_NORMAL);
|
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);
|
setLoading(true);
|
||||||
try {
|
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 rows = res.data?.data?.notifications ?? [];
|
||||||
|
const pag = res.data?.data?.pagination ?? { page, limit, pages: 1, total: rows.length };
|
||||||
setNotifications(rows);
|
setNotifications(rows);
|
||||||
setUnseenCount(rows.filter(n => !n.seen).length);
|
setPagination({ ...pag, pages: Math.max(1, pag.pages) });
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
} finally {
|
} 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) => {
|
const markSeen = useCallback(async (id) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/client/notifications/${id}/seen`);
|
await api.patch(`/client/notifications/${id}/seen`);
|
||||||
@@ -93,9 +109,11 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
notifications,
|
notifications,
|
||||||
unseenCount,
|
unseenCount,
|
||||||
loading,
|
loading,
|
||||||
|
pagination,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
markSeen,
|
markSeen,
|
||||||
markAllSeen,
|
markAllSeen,
|
||||||
|
clearAll,
|
||||||
accelerate,
|
accelerate,
|
||||||
decelerate,
|
decelerate,
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -97,12 +97,10 @@ export function ClientTiersProvider({ children }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Returns { valid, code, type, value, discount, reason } from the server
|
// 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);
|
setPromoLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload = { plan_id, code };
|
const { data } = await api.post('/client/tiers/promos/validate', { plan_id, code });
|
||||||
if (currency) payload.currency = currency;
|
|
||||||
const { data } = await api.post('/client/tiers/promos/validate', payload);
|
|
||||||
return data.data ?? { valid: false, reason: 'No response from server.' };
|
return data.data ?? { valid: false, reason: 'No response from server.' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { valid: false, reason: err?.response?.data?.message ?? 'Invalid promo code.' };
|
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
|
// 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);
|
setCheckoutLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload = { plan_id };
|
const payload = { plan_id };
|
||||||
if (promo_code) payload.promo_code = promo_code;
|
if (promo_code) payload.promo_code = promo_code;
|
||||||
if (currency) payload.currency = currency;
|
|
||||||
const { data } = await api.post("/client/tiers/checkout/order", payload);
|
const { data } = await api.post("/client/tiers/checkout/order", payload);
|
||||||
return data.data ?? null;
|
return data.data ?? null;
|
||||||
} catch (err) {
|
} 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);
|
setProfileLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get(`${apiBase}/profile`);
|
const { data } = await api.get(`${apiBase}/profile`);
|
||||||
setProfile(data.data ?? null);
|
const fresh = data.data ?? null;
|
||||||
|
setProfile(fresh);
|
||||||
|
return fresh;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.response?.data?.message ?? "Could not load profile.");
|
toast.error(err?.response?.data?.message ?? "Could not load profile.");
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setProfileLoading(false);
|
setProfileLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AdminTiersProvider } from "../AdminTiersContext";
|
|||||||
import { AdminCategoriesProvider } from "../AdminCategoriesContext";
|
import { AdminCategoriesProvider } from "../AdminCategoriesContext";
|
||||||
import { ProfileProvider } from "../ProfileProvider";
|
import { ProfileProvider } from "../ProfileProvider";
|
||||||
import { AdvertisementsProvider } from "../AdminAdvertisementContext";
|
import { AdvertisementsProvider } from "../AdminAdvertisementContext";
|
||||||
|
import { NotificationBroadcastsProvider } from "../AdminNotificationBroadcastContext";
|
||||||
import { AdminNotificationProvider } from "../AdminNotificationContext"
|
import { AdminNotificationProvider } from "../AdminNotificationContext"
|
||||||
import { AdminCourseReadingProgressProvider } from "../AdminCourseReadingProgressContext";
|
import { AdminCourseReadingProgressProvider } from "../AdminCourseReadingProgressContext";
|
||||||
|
|
||||||
@@ -19,21 +20,23 @@ export const AdminProvider = ({ children }) => {
|
|||||||
<ProfileProvider apiBase="/admin">
|
<ProfileProvider apiBase="/admin">
|
||||||
<AssetsProvider>
|
<AssetsProvider>
|
||||||
<AdvertisementsProvider>
|
<AdvertisementsProvider>
|
||||||
<UserProvider>
|
<NotificationBroadcastsProvider>
|
||||||
<UserGroupProvider>
|
<UserProvider>
|
||||||
<AdminTiersProvider>
|
<UserGroupProvider>
|
||||||
<AdminCategoriesProvider>
|
<AdminTiersProvider>
|
||||||
<CoursesProvider>
|
<AdminCategoriesProvider>
|
||||||
<AdminCourseReadingProgressProvider>
|
<CoursesProvider>
|
||||||
<AdminTaskProvider>
|
<AdminCourseReadingProgressProvider>
|
||||||
{children}
|
<AdminTaskProvider>
|
||||||
</AdminTaskProvider>
|
{children}
|
||||||
</AdminCourseReadingProgressProvider>
|
</AdminTaskProvider>
|
||||||
</CoursesProvider>
|
</AdminCourseReadingProgressProvider>
|
||||||
</AdminCategoriesProvider>
|
</CoursesProvider>
|
||||||
</AdminTiersProvider>
|
</AdminCategoriesProvider>
|
||||||
</UserGroupProvider>
|
</AdminTiersProvider>
|
||||||
</UserProvider>
|
</UserGroupProvider>
|
||||||
|
</UserProvider>
|
||||||
|
</NotificationBroadcastsProvider>
|
||||||
</AdvertisementsProvider>
|
</AdvertisementsProvider>
|
||||||
</AssetsProvider>
|
</AssetsProvider>
|
||||||
</ProfileProvider>
|
</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 = [
|
export const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -39,6 +39,9 @@ export const ADMIN_SECTIONS = [
|
|||||||
description: "Manage public-facing content",
|
description: "Manage public-facing content",
|
||||||
tiles: [
|
tiles: [
|
||||||
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
|
{ 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 {
|
.dark {
|
||||||
--background: oklch(0.21 0.034 264.665);
|
--background: oklch(0.17 0.035 278);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.97 0.01 280);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.2 0.045 280);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.97 0.01 280);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.2 0.045 280);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.97 0.01 280);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.63 0.25 302);
|
||||||
--primary-green: oklch(79.2% 0.209 151.711);
|
--primary-green: oklch(79.2% 0.209 151.711);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.98 0.012 302);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.25 0.04 280);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.97 0.01 280);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.22 0.035 280);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.68 0.03 280);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.28 0.06 285);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.97 0.01 280);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(0.55 0.08 280 / 15%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(0.55 0.08 280 / 20%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.63 0.25 302);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.63 0.25 302);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.68 0.28 330);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.62 0.21 260);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.7 0.18 200);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.75 0.19 70);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.13 0.03 280);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.97 0.01 280);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.63 0.25 302);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.98 0.012 302);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.22 0.05 280);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.97 0.01 280);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(0.55 0.08 280 / 12%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.63 0.25 302);
|
||||||
--destructive-foreground: var(--color-red-400);
|
--destructive-foreground: var(--color-red-400);
|
||||||
--info: var(--color-blue-500);
|
--info: var(--color-blue-500);
|
||||||
--info-foreground: var(--color-blue-400);
|
--info-foreground: var(--color-blue-400);
|
||||||
@@ -239,4 +239,12 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(entry)}
|
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} />
|
<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 (
|
return (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
{/* ── Summary strip ── */}
|
{/* ── Summary strip ── */}
|
||||||
<div className="flex items-center gap-4 flex-wrap text-sm">
|
<div className="flex items-center gap-4 flex-wrap text-sm">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
@@ -350,7 +350,7 @@ export default function CourseReadingProgressList({ courseId }) {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
|
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
|
||||||
maxLength={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'}`}>
|
<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
|
{search.length}/50
|
||||||
@@ -382,6 +382,6 @@ export default function CourseReadingProgressList({ courseId }) {
|
|||||||
entry={dialogEntry}
|
entry={dialogEntry}
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
@@ -119,6 +120,16 @@ export default function CoursesTable() {
|
|||||||
onArchive={(c) => archiveCourse(c?.course_id)}
|
onArchive={(c) => archiveCourse(c?.course_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleArchiveSuccess}
|
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 ── */}
|
{/* ── Bulk archive ── */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useRef, useState, useCallback } from "react";
|
import { useMemo, useRef, useState, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
@@ -128,6 +129,16 @@ export default function UnitsTable({ courseId }) {
|
|||||||
onArchive={(c) => archiveUnit(courseId, c?.unit_id)}
|
onArchive={(c) => archiveUnit(courseId, c?.unit_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleArchiveSuccess}
|
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 ── */}
|
{/* ── Bulk archive ── */}
|
||||||
|
|||||||
@@ -19,13 +19,23 @@ import { cn } from "@/lib/utils";
|
|||||||
* isPreloaded — true in EditPlan (CoursePicker only mounts AFTER existing
|
* isPreloaded — true in EditPlan (CoursePicker only mounts AFTER existing
|
||||||
* assignments are already in selectedIds, so no race condition).
|
* assignments are already in selectedIds, so no race condition).
|
||||||
* false in AddPlan (always bundle all on first load).
|
* 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:
|
* Flow:
|
||||||
* • Shows "Bundle all?" question with two buttons.
|
* • Shows "Bundle all?" question with two buttons.
|
||||||
* • "Yes, include all" → selects every course in the tier, hides picker.
|
* • "Yes, include all" → selects every course in the tier, hides picker.
|
||||||
* • "No, choose specific" → opens a Popover with Command+Search+Checkboxes.
|
* • "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 [courses, setCourses] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [bundleAll, setBundleAll] = useState(true);
|
const [bundleAll, setBundleAll] = useState(true);
|
||||||
@@ -75,6 +85,29 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
|
|||||||
);
|
);
|
||||||
}, [courses, search]);
|
}, [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 toggle = (id) => {
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
@@ -147,6 +180,26 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
|
|||||||
</p>
|
</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 ───────────────────────────────────────── */}
|
{/* ── No courses in tier ───────────────────────────────────────── */}
|
||||||
{!loading && total === 0 && (
|
{!loading && total === 0 && (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
<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">
|
<ScrollArea className="h-64">
|
||||||
{filtered.map((course) => {
|
{filtered.map((course) => {
|
||||||
const id = String(course.course_id);
|
const id = String(course.course_id);
|
||||||
const checked = selectedIds.has(id);
|
const checked = selectedIds.has(id);
|
||||||
|
const conflict = isConflict(course);
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={id}
|
key={id}
|
||||||
@@ -212,6 +266,12 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
|
|||||||
{course.description}
|
{course.description}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</CommandItem>
|
</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 }) {
|
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment }) {
|
||||||
return [
|
return [
|
||||||
@@ -8,19 +8,21 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
|
|||||||
icon: <Eye className="h-3.5 w-3.5" />,
|
icon: <Eye className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => onView(row),
|
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",
|
key: "view_units",
|
||||||
label: "View Units",
|
label: "View Units",
|
||||||
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
|
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
|
||||||
className: "text-sky-700 hover:text-sky-600",
|
className: "text-sky-700 hover:text-sky-600",
|
||||||
onClick: (row) => onViewUnits(row),
|
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",
|
key: "view_assessment",
|
||||||
@@ -28,7 +30,7 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
|
|||||||
icon: <ClipboardList className="h-3.5 w-3.5" />,
|
icon: <ClipboardList className="h-3.5 w-3.5" />,
|
||||||
className: "text-purple-700 hover:text-purple-600",
|
className: "text-purple-700 hover:text-purple-600",
|
||||||
onClick: (row) => onViewAssessment(row),
|
onClick: (row) => onViewAssessment(row),
|
||||||
separator: true,
|
hidden: (row) => !row.assessment_id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "modify_assessment",
|
key: "modify_assessment",
|
||||||
@@ -36,6 +38,7 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
|
|||||||
icon: <NotebookPen className="h-3.5 w-3.5" />,
|
icon: <NotebookPen className="h-3.5 w-3.5" />,
|
||||||
className: "text-purple-700 hover:text-purple-600",
|
className: "text-purple-700 hover:text-purple-600",
|
||||||
onClick: (row) => onAssessment(row),
|
onClick: (row) => onAssessment(row),
|
||||||
|
hidden: (row) => !row.assessment_id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "archive",
|
key: "archive",
|
||||||
@@ -43,7 +46,7 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
|
|||||||
icon: <Archive className="h-3.5 w-3.5" />,
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
className: "text-destructive",
|
className: "text-destructive",
|
||||||
onClick: (row) => onArchive(row),
|
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 }) {
|
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz, onViewQuiz }) {
|
||||||
return [
|
return [
|
||||||
@@ -20,7 +20,16 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
|
|||||||
icon: <BookCheck className="h-3.5 w-3.5" />,
|
icon: <BookCheck className="h-3.5 w-3.5" />,
|
||||||
className: "text-sky-700 hover:text-sky-600",
|
className: "text-sky-700 hover:text-sky-600",
|
||||||
onClick: (row) => onViewLessons(row),
|
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",
|
key: "view_quiz",
|
||||||
@@ -28,6 +37,7 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
|
|||||||
icon: <ClipboardList className="h-3.5 w-3.5" />,
|
icon: <ClipboardList className="h-3.5 w-3.5" />,
|
||||||
className: "text-purple-700 hover:text-purple-600",
|
className: "text-purple-700 hover:text-purple-600",
|
||||||
onClick: (row) => onViewQuiz(row),
|
onClick: (row) => onViewQuiz(row),
|
||||||
|
hidden: (row) => !(row.quiz_id || row.quiz),
|
||||||
separator: true,
|
separator: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -36,6 +46,7 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
|
|||||||
icon: <NotebookPen className="h-3.5 w-3.5" />,
|
icon: <NotebookPen className="h-3.5 w-3.5" />,
|
||||||
className: "text-purple-700 hover:text-purple-600",
|
className: "text-purple-700 hover:text-purple-600",
|
||||||
onClick: (row) => onQuiz(row),
|
onClick: (row) => onQuiz(row),
|
||||||
|
hidden: (row) => !(row.quiz_id || row.quiz),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "archive",
|
key: "archive",
|
||||||
@@ -43,7 +54,7 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
|
|||||||
icon: <Archive className="h-3.5 w-3.5" />,
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
className: "text-destructive",
|
className: "text-destructive",
|
||||||
onClick: (row) => onArchive(row),
|
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 = {}) {
|
export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v ? new Date(v).toLocaleString() : "—", tierMap = {}) {
|
||||||
const cellOverrides = {
|
const cellOverrides = {
|
||||||
status: (info) => (
|
user_full_name: (info) => (
|
||||||
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
|
<span className="text-sm font-medium">{info.getValue() ?? "—"}</span>
|
||||||
{info.getValue()}
|
),
|
||||||
</Badge>
|
"user.email": (info) => (
|
||||||
|
<span className="text-sm">{info.getValue() ?? "—"}</span>
|
||||||
),
|
),
|
||||||
amount: (info) => {
|
amount: (info) => {
|
||||||
const row = info.row.original;
|
const row = info.row.original;
|
||||||
@@ -33,6 +34,11 @@ export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
status: (info) => (
|
||||||
|
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
|
||||||
|
{info.getValue()}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
"plan.tier": (info) => {
|
"plan.tier": (info) => {
|
||||||
const { cls, label } = resolveTierBadge(info.getValue(), tierMap);
|
const { cls, label } = resolveTierBadge(info.getValue(), tierMap);
|
||||||
return <Badge className={`${cls} capitalize`}>{label}</Badge>;
|
return <Badge className={`${cls} capitalize`}>{label}</Badge>;
|
||||||
@@ -42,9 +48,6 @@ export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v
|
|||||||
{fmtDateTime(info.getValue())}
|
{fmtDateTime(info.getValue())}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
"user.email": (info) => (
|
|
||||||
<span className="text-sm">{info.getValue() ?? "—"}</span>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
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 }) {
|
export function buildRowActions({ navigate, onArchive }) {
|
||||||
return [
|
return [
|
||||||
@@ -8,28 +8,6 @@ export function buildRowActions({ navigate, onArchive }) {
|
|||||||
icon: <Eye className="h-3.5 w-3.5" />,
|
icon: <Eye className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/view`),
|
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",
|
key: "archive",
|
||||||
label: "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";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
export function buildToolbarActions({
|
export function buildToolbarActions({
|
||||||
@@ -44,14 +44,6 @@ export function buildToolbarActions({
|
|||||||
variant: "outline",
|
variant: "outline",
|
||||||
onClick: () => navigate("/admin/tiers/categories"),
|
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",
|
key: "create",
|
||||||
type: "button",
|
type: "button",
|
||||||
|
|||||||
@@ -84,14 +84,14 @@ const AdminLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 />
|
<Outlet />
|
||||||
<Toaster position="bottom-right" richColors />
|
<Toaster position="bottom-right" richColors />
|
||||||
</div>
|
</div>
|
||||||
</AdminProvider>
|
</AdminProvider>
|
||||||
|
|
||||||
{/* Footer sits outside AdminProvider intentionally */}
|
{/* 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
|
© Philproperties, 2026
|
||||||
</footer>
|
</footer>
|
||||||
</TooltipProvider>
|
</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
|
// modules/admin/pages/advertisements/AddAdvertisement.jsx
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useForm, useFieldArray } from "react-hook-form";
|
import { useForm, useFieldArray } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/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 { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -16,15 +21,19 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const schema = z.object({
|
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(),
|
badge_label: z.string().optional(),
|
||||||
headline: z.string().optional(),
|
headline: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -39,9 +48,10 @@ const schema = z.object({
|
|||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
order: z.coerce.number().min(0).default(0),
|
order: z.coerce.number().min(0).default(0),
|
||||||
is_active: z.boolean().default(true),
|
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) => {
|
}).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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.too_big,
|
code: z.ZodIssueCode.too_big,
|
||||||
maximum: 200,
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function FieldError({ message }) {
|
function FieldError({ message }) {
|
||||||
@@ -60,24 +82,11 @@ function FieldError({ message }) {
|
|||||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
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 = [
|
const BANNER_SIZES = [
|
||||||
{ value: "sm", label: "Small" },
|
{ value: "sm", label: "Small" },
|
||||||
{ value: "md", label: "Medium" },
|
{ value: "md", label: "Medium" },
|
||||||
{ value: "lg", label: "Large" },
|
{ value: "lg", label: "Large" },
|
||||||
|
{ value: "xl", label: "Extra Large" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CTA_VARIANTS = [
|
const CTA_VARIANTS = [
|
||||||
@@ -85,6 +94,348 @@ const CTA_VARIANTS = [
|
|||||||
{ value: "outline", label: "Outline" },
|
{ 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 ───────────────────────────────────────────────────────────────────
|
// ─── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AddAdvertisement() {
|
export default function AddAdvertisement() {
|
||||||
@@ -94,18 +445,23 @@ export default function AddAdvertisement() {
|
|||||||
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||||
|
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
|
||||||
|
const [selectedPage, setSelectedPage] = useState(null);
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
|
trigger,
|
||||||
|
getValues,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
type: undefined,
|
placement: undefined,
|
||||||
badge_label: "",
|
badge_label: "",
|
||||||
headline: "",
|
headline: "",
|
||||||
description: "",
|
description: "",
|
||||||
@@ -121,10 +477,19 @@ export default function AddAdvertisement() {
|
|||||||
|
|
||||||
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
||||||
|
|
||||||
const type = watch("type");
|
const placement = watch("placement");
|
||||||
const description = watch("description");
|
const description = watch("description");
|
||||||
const showRichContent = RICH_CONTENT_TYPES.includes(type);
|
const format = PLACEMENT_MAP[placement]?.format;
|
||||||
const isBanner = type === "banner";
|
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 = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
@@ -132,19 +497,33 @@ export default function AddAdvertisement() {
|
|||||||
{ label: "New" },
|
{ 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 = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
image_asset_id: values.image_asset_id || null,
|
image_asset_id: values.image_asset_id || null,
|
||||||
start_date: values.start_date || null,
|
start_date: values.start_date || null,
|
||||||
end_date: values.end_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,
|
createdBy: user?.user_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await createAdvertisement(payload);
|
const res = await createAdvertisement(payload);
|
||||||
if (res) navigate("/admin/advertisements");
|
if (res) navigate("/admin/advertisements");
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-muted h-full">
|
<section className="bg-muted h-full">
|
||||||
@@ -153,204 +532,87 @@ export default function AddAdvertisement() {
|
|||||||
<AppBreadcrumb items={breadcrumbItems} />
|
<AppBreadcrumb items={breadcrumbItems} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-2xl pb-10">
|
<div className="w-full max-w-2xl pb-10 space-y-6">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground mb-6">Create a banner, popup, or hero placement.</p>
|
<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 className="rounded-lg border bg-card p-6 min-h-[360px]">
|
||||||
<div>
|
<div className="space-y-0.5 pb-4 mb-1 border-b">
|
||||||
<Label className="mb-1.5 block">Type</Label>
|
<h2 className="text-sm font-semibold">{current.label}</h2>
|
||||||
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true })}>
|
<p className="text-xs text-muted-foreground">{current.description}</p>
|
||||||
<SelectTrigger>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{isBanner && (
|
{current.id === "placement" && (
|
||||||
<div>
|
<StepPlacement
|
||||||
<Label className="mb-1.5 block">Size</Label>
|
selectedPage={selectedPage}
|
||||||
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
|
setSelectedPage={setSelectedPage}
|
||||||
<SelectTrigger>
|
placement={placement}
|
||||||
<SelectValue placeholder="Select a size" />
|
setValue={setValue}
|
||||||
</SelectTrigger>
|
positionOptions={positionOptions}
|
||||||
<SelectContent>
|
errors={errors}
|
||||||
{BANNER_SIZES.map((s) => (
|
format={format}
|
||||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
isBanner={isBanner}
|
||||||
))}
|
watch={watch}
|
||||||
</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 === "content" && (
|
||||||
{!showRichContent && (
|
<StepContent
|
||||||
<SectionCard title="Content" description="Optional headline for this placement.">
|
register={register}
|
||||||
<div>
|
errors={errors}
|
||||||
<Label className="mb-1.5 block">Headline (optional)</Label>
|
showRichContent={showRichContent}
|
||||||
<Input placeholder="Internal label for this ad" {...register("headline")} />
|
description={description}
|
||||||
</div>
|
format={format}
|
||||||
</SectionCard>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{current.id === "image" && (
|
||||||
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
|
<StepImage selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} setPickerOpen={setPickerOpen} />
|
||||||
{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 === "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="flex justify-between gap-2">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<Button
|
||||||
<div>
|
type="button"
|
||||||
<Label className="mb-1.5 block">Start date</Label>
|
variant="outline"
|
||||||
<Input type="datetime-local" {...register("start_date")} />
|
onClick={stepIndex === 0 ? () => navigate(-1) : handleBack}
|
||||||
</div>
|
disabled={loading}
|
||||||
<div>
|
>
|
||||||
<Label className="mb-1.5 block">End date</Label>
|
<ChevronLeft className="size-4" />
|
||||||
<Input type="datetime-local" {...register("end_date")} />
|
{stepIndex === 0 ? "Cancel" : "Back"}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard title="Display" description="Manual ordering and on/off switch.">
|
{stepIndex === steps.length - 1 ? (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<Button type="button" onClick={handleCreate} disabled={loading}>
|
||||||
<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}>
|
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Create advertisement
|
Create advertisement
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : (
|
||||||
</form>
|
<Button type="button" onClick={handleNext}>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -358,11 +620,12 @@ export default function AddAdvertisement() {
|
|||||||
open={pickerOpen}
|
open={pickerOpen}
|
||||||
onOpenChange={setPickerOpen}
|
onOpenChange={setPickerOpen}
|
||||||
fileType="image"
|
fileType="image"
|
||||||
onSelect={(asset) => {
|
onSelect={(asset, resolvedUrl) => {
|
||||||
setSelectedAsset(asset);
|
setSelectedAsset(asset);
|
||||||
|
setImagePreviewUrl(resolvedUrl ?? null);
|
||||||
setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
|
setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from "lucide-react";
|
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -17,24 +18,27 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
||||||
|
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
||||||
|
|
||||||
export default function AdvertisementList() {
|
export default function AdvertisementList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
|
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
|
||||||
|
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [placementFilter, setPlacementFilter] = useState("all");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filters = [];
|
const filters = [];
|
||||||
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
|
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
|
||||||
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
|
if (placementFilter !== "all") filters.push({ field: "placement", value: placementFilter });
|
||||||
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
|
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 });
|
fetchAdvertisements({ page: 1, limit: 24, filters });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [typeFilter, statusFilter, search]);
|
}, [typeFilter, placementFilter, statusFilter, search]);
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
@@ -82,7 +86,7 @@ export default function AdvertisementList() {
|
|||||||
{/* ── Filters ────────────────────────────────────────────────── */}
|
{/* ── Filters ────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[150px] bg-background">
|
||||||
<SelectValue placeholder="All types" />
|
<SelectValue placeholder="All types" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -93,8 +97,20 @@ export default function AdvertisementList() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[150px] bg-background">
|
||||||
<SelectValue placeholder="All statuses" />
|
<SelectValue placeholder="All statuses" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<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" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search advertisements..."
|
placeholder="Search advertisements..."
|
||||||
className="pl-8"
|
className="pl-8 bg-background"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -164,11 +180,12 @@ function StatCard({ label, value, tone = "default" }) {
|
|||||||
|
|
||||||
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
||||||
const { fmtDate } = useDateFormat();
|
const { fmtDate } = useDateFormat();
|
||||||
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
|
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
|
||||||
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
|
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
|
||||||
const TypeIcon = typeMeta.icon ?? Megaphone;
|
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 isDimmed = ad.status === "expired" || ad.status === "archived";
|
||||||
|
|
||||||
const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDate);
|
const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDate);
|
||||||
@@ -178,13 +195,13 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onView}
|
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"
|
aria-label="View advertisement details"
|
||||||
>
|
>
|
||||||
{previewSrc ? (
|
{previewSrc ? (
|
||||||
<img src={previewSrc} alt={ad.headline || ad.type} className="w-full h-full object-cover" />
|
<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"}`}>
|
<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">
|
<div className="p-3 flex flex-col gap-2 flex-1">
|
||||||
<button type="button" onClick={onView} className="text-left">
|
<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>
|
<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>}
|
{dateRange && <p className="text-xs text-muted-foreground mt-0.5">{dateRange}</p>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { House, Plus, Trash2, ImagePlus } from "lucide-react";
|
|||||||
|
|
||||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const schema = z.object({
|
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(),
|
badge_label: z.string().optional(),
|
||||||
headline: z.string().optional(),
|
headline: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -39,9 +41,10 @@ const schema = z.object({
|
|||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
order: z.coerce.number().min(0).default(0),
|
order: z.coerce.number().min(0).default(0),
|
||||||
is_active: z.boolean().default(true),
|
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) => {
|
}).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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.too_big,
|
code: z.ZodIssueCode.too_big,
|
||||||
maximum: 200,
|
maximum: 200,
|
||||||
@@ -86,6 +89,7 @@ const BANNER_SIZES = [
|
|||||||
{ value: "sm", label: "Small" },
|
{ value: "sm", label: "Small" },
|
||||||
{ value: "md", label: "Medium" },
|
{ value: "md", label: "Medium" },
|
||||||
{ value: "lg", label: "Large" },
|
{ value: "lg", label: "Large" },
|
||||||
|
{ value: "xl", label: "Extra Large" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CTA_VARIANTS = [
|
const CTA_VARIANTS = [
|
||||||
@@ -103,6 +107,15 @@ export default function EditAdvertisement() {
|
|||||||
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -115,7 +128,7 @@ export default function EditAdvertisement() {
|
|||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
type: undefined,
|
placement: undefined,
|
||||||
badge_label: "",
|
badge_label: "",
|
||||||
headline: "",
|
headline: "",
|
||||||
description: "",
|
description: "",
|
||||||
@@ -131,10 +144,12 @@ export default function EditAdvertisement() {
|
|||||||
|
|
||||||
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
||||||
|
|
||||||
const type = watch("type");
|
const placement = watch("placement");
|
||||||
const description = watch("description");
|
const description = watch("description");
|
||||||
const showRichContent = RICH_CONTENT_TYPES.includes(type);
|
const format = PLACEMENT_MAP[placement]?.format;
|
||||||
const isBanner = type === "banner";
|
const showRichContent = RICH_CONTENT_TYPES.includes(format);
|
||||||
|
const isBanner = format === "banner";
|
||||||
|
const positionOptions = AD_PAGES.find((p) => p.page === selectedPage)?.placements ?? [];
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
@@ -149,10 +164,17 @@ export default function EditAdvertisement() {
|
|||||||
const ad = res?.data?.data ?? null;
|
const ad = res?.data?.data ?? null;
|
||||||
if (!ad) return;
|
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({
|
reset({
|
||||||
type: ad.type ?? undefined,
|
placement: ad.placement ?? undefined,
|
||||||
badge_label: ad.badge_label ?? "",
|
badge_label: ad.badge_label ?? "",
|
||||||
headline: ad.headline ?? "",
|
headline: ad.headline ?? "",
|
||||||
description: ad.description ?? "",
|
description: ad.description ?? "",
|
||||||
@@ -168,6 +190,8 @@ export default function EditAdvertisement() {
|
|||||||
is_active: ad.is_active ?? true,
|
is_active: ad.is_active ?? true,
|
||||||
size: ad.size ?? null,
|
size: ad.size ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setReady(true);
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [advertisementId]);
|
}, [advertisementId]);
|
||||||
@@ -180,7 +204,7 @@ export default function EditAdvertisement() {
|
|||||||
image_asset_id: values.image_asset_id || null,
|
image_asset_id: values.image_asset_id || null,
|
||||||
start_date: values.start_date || null,
|
start_date: values.start_date || null,
|
||||||
end_date: values.end_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,
|
updatedBy: user?.user_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,6 +212,16 @@ export default function EditAdvertisement() {
|
|||||||
if (res) navigate("/admin/advertisements");
|
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 (
|
return (
|
||||||
<section className="bg-muted h-full">
|
<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="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">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
|
||||||
<SectionCard title="Placement" description="Where this advertisement will appear.">
|
<SectionCard title="Placement" description="Where this advertisement will appear.">
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Label className="mb-1.5 block">Type</Label>
|
<div>
|
||||||
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true, shouldDirty: true })}>
|
<Label className="mb-1.5 block">Page</Label>
|
||||||
<SelectTrigger>
|
<Select
|
||||||
<SelectValue placeholder="Select a type" />
|
value={selectedPage ?? undefined}
|
||||||
</SelectTrigger>
|
onValueChange={(v) => {
|
||||||
<SelectContent>
|
setSelectedPage(v);
|
||||||
{ADVERTISEMENT_TYPES.map((t) => (
|
setValue("placement", "", { shouldValidate: false, shouldDirty: true });
|
||||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
}}
|
||||||
))}
|
>
|
||||||
</SelectContent>
|
<SelectTrigger>
|
||||||
</Select>
|
<SelectValue placeholder="Select a page" />
|
||||||
<FieldError message={errors.type?.message} />
|
</SelectTrigger>
|
||||||
{type && (
|
<SelectContent>
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
{AD_PAGES.map((p) => (
|
||||||
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
|
<SelectItem key={p.page} value={p.page}>{p.pageLabel}</SelectItem>
|
||||||
</p>
|
))}
|
||||||
)}
|
</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>
|
</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 && (
|
{isBanner && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Size</Label>
|
<Label className="mb-1.5 block">Size</Label>
|
||||||
@@ -255,7 +315,7 @@ export default function EditAdvertisement() {
|
|||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Description</Label>
|
<Label className="mb-1.5 block">Description</Label>
|
||||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||||
{type === "hero" && (
|
{format === "hero" && (
|
||||||
<div className="flex justify-between items-start mt-1">
|
<div className="flex justify-between items-start mt-1">
|
||||||
<FieldError message={errors.description?.message} />
|
<FieldError message={errors.description?.message} />
|
||||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
<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)}
|
onClick={() => setPickerOpen(true)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
|
src={imagePreviewUrl || resolveAssetSrc(selectedAsset)}
|
||||||
alt={selectedAsset.display_name}
|
alt={selectedAsset.display_name}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -400,8 +460,9 @@ export default function EditAdvertisement() {
|
|||||||
open={pickerOpen}
|
open={pickerOpen}
|
||||||
onOpenChange={setPickerOpen}
|
onOpenChange={setPickerOpen}
|
||||||
fileType="image"
|
fileType="image"
|
||||||
onSelect={(asset) => {
|
onSelect={(asset, resolvedUrl) => {
|
||||||
setSelectedAsset(asset);
|
setSelectedAsset(asset);
|
||||||
|
setImagePreviewUrl(resolvedUrl ?? null);
|
||||||
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
|
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 { House, Edit, ArrowLeft, Megaphone, MousePointerClick, ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -12,6 +13,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
import { ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
import { ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
||||||
|
import { PLACEMENT_MAP } from "@/data/placement.data";
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -85,11 +87,12 @@ export default function ViewAdvertisement() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeMeta = ADVERTISEMENT_TYPE_MAP[advertisement.type] ?? {};
|
const typeMeta = ADVERTISEMENT_TYPE_MAP[advertisement.type] ?? {};
|
||||||
const statusMeta = ADVERTISEMENT_STATUS_MAP[advertisement.status] ?? {};
|
const statusMeta = ADVERTISEMENT_STATUS_MAP[advertisement.status] ?? {};
|
||||||
const TypeIcon = typeMeta.icon ?? Megaphone;
|
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 : [];
|
const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,7 +114,7 @@ export default function ViewAdvertisement() {
|
|||||||
<h1 className="text-xl font-semibold tracking-tight">
|
<h1 className="text-xl font-semibold tracking-tight">
|
||||||
{advertisement.headline || advertisement.badge_label || "Untitled advertisement"}
|
{advertisement.headline || advertisement.badge_label || "Untitled advertisement"}
|
||||||
</h1>
|
</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">
|
<Badge variant="secondary" className="gap-1">
|
||||||
<TypeIcon className="size-3" />
|
<TypeIcon className="size-3" />
|
||||||
{typeMeta.label ?? advertisement.type}
|
{typeMeta.label ?? advertisement.type}
|
||||||
@@ -119,6 +122,15 @@ export default function ViewAdvertisement() {
|
|||||||
<Badge variant={advertisement.status === "active" ? "default" : "secondary"}>
|
<Badge variant={advertisement.status === "active" ? "default" : "secondary"}>
|
||||||
{statusMeta.label ?? advertisement.status}
|
{statusMeta.label ?? advertisement.status}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import api from "@/utils/api.util";
|
|||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
|
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
|
||||||
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
|
|
||||||
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
|
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
|
||||||
|
|
||||||
@@ -49,6 +49,14 @@ export default function ViewAudioAsset() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
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 })
|
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
const { token, thumbnail_url } = data?.data ?? {};
|
const { token, thumbnail_url } = data?.data ?? {};
|
||||||
@@ -59,11 +67,7 @@ export default function ViewAudioAsset() {
|
|||||||
}, [selectedAsset]);
|
}, [selectedAsset]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <MediaFallback className="h-96 rounded-lg" />;
|
||||||
<div className="flex items-center justify-center h-96">
|
|
||||||
<Spinner className="size-8" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedAsset) {
|
if (!selectedAsset) {
|
||||||
|
|||||||
@@ -43,10 +43,15 @@ export default function ViewDocumentAsset() {
|
|||||||
setStreamUrl(selectedAsset.file_url ?? null);
|
setStreamUrl(selectedAsset.file_url ?? null);
|
||||||
return;
|
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 })
|
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
|
||||||
.then(({ data }) => setStreamUrl(
|
.then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
|
||||||
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
|
|
||||||
))
|
|
||||||
.catch(() => setStreamUrl(null));
|
.catch(() => setStreamUrl(null));
|
||||||
}, [selectedAsset]);
|
}, [selectedAsset]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { ArrowLeft, Lock, Globe } from "lucide-react";
|
|||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
|
|
||||||
function MetaRow({ label, value }) {
|
function MetaRow({ label, value }) {
|
||||||
if (!value && value !== 0) return null;
|
if (!value && value !== 0) return null;
|
||||||
@@ -41,19 +41,20 @@ export default function ViewImageAsset() {
|
|||||||
setStreamUrl(selectedAsset.file_url ?? null);
|
setStreamUrl(selectedAsset.file_url ?? null);
|
||||||
return;
|
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 })
|
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
|
||||||
.then(({ data }) => setStreamUrl(
|
.then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
|
||||||
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
|
|
||||||
))
|
|
||||||
.catch(() => setStreamUrl(null));
|
.catch(() => setStreamUrl(null));
|
||||||
}, [selectedAsset]);
|
}, [selectedAsset]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <MediaFallback className="h-96 rounded-lg" />;
|
||||||
<div className="flex items-center justify-center h-96">
|
|
||||||
<Spinner className="size-8" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedAsset) {
|
if (!selectedAsset) {
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import api from "@/utils/api.util";
|
|||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
|
|
||||||
function MetaRow({ label, value }) {
|
function MetaRow({ label, value }) {
|
||||||
if (!value && value !== 0) return null;
|
if (!value && value !== 0) return null;
|
||||||
@@ -51,19 +51,20 @@ export default function ViewVideoAsset() {
|
|||||||
setStreamUrl(selectedAsset.file_url ?? null);
|
setStreamUrl(selectedAsset.file_url ?? null);
|
||||||
return;
|
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 })
|
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
|
||||||
.then(({ data }) => setStreamUrl(
|
.then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
|
||||||
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
|
|
||||||
))
|
|
||||||
.catch(() => setStreamUrl(null));
|
.catch(() => setStreamUrl(null));
|
||||||
}, [selectedAsset]);
|
}, [selectedAsset]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <MediaFallback className="h-96 rounded-lg" />;
|
||||||
<div className="flex items-center justify-center h-96">
|
|
||||||
<Spinner className="size-8" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedAsset) {
|
if (!selectedAsset) {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm, useFieldArray, useWatch } from "react-hook-form";
|
import { useForm, useFieldArray, useWatch } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/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 { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
@@ -17,28 +20,16 @@ import { Spinner } from "@/components/ui/spinner";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover, PopoverContent, PopoverTrigger,
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||||
import { ACHIEVEMENT_REGISTRY } from "@/utils/achievements.data";
|
|
||||||
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
|
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
|
||||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||||
|
|
||||||
@@ -52,9 +43,16 @@ const schema = z.object({
|
|||||||
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||||
subscription: z.string().min(1, "Subscription is required.").default("free"),
|
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([]),
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function FieldError({ message }) {
|
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 ─────────────────────────────────────────────────────────────────────
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AddCourse() {
|
export default function AddCourse() {
|
||||||
@@ -83,6 +131,7 @@ export default function AddCourse() {
|
|||||||
const { createCourse, loading } = useCourses();
|
const { createCourse, loading } = useCourses();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [tierCategories, setTierCategories] = useState([]);
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/admin/tiers/categories")
|
api.get("/admin/tiers/categories")
|
||||||
@@ -90,7 +139,6 @@ export default function AddCourse() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─── Badge config state ─────────────────────────────────────────────────
|
|
||||||
const [badgeColor, setBadgeColor] = useState("purple");
|
const [badgeColor, setBadgeColor] = useState("purple");
|
||||||
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
||||||
const [badgeAssetId, setBadgeAssetId] = useState(null);
|
const [badgeAssetId, setBadgeAssetId] = useState(null);
|
||||||
@@ -100,6 +148,7 @@ export default function AddCourse() {
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
trigger,
|
||||||
control,
|
control,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
@@ -120,19 +169,34 @@ export default function AddCourse() {
|
|||||||
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
||||||
useFieldArray({ control, name: "objectives" });
|
useFieldArray({ control, name: "objectives" });
|
||||||
|
|
||||||
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
|
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
|
||||||
const watchedTitle = useWatch({ control, name: "title" });
|
const watchedTitle = useWatch({ control, name: "title" });
|
||||||
const watchedLevel = useWatch({ control, name: "level" });
|
const watchedLevel = useWatch({ control, name: "level" });
|
||||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
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) => {
|
const toggleAchievement = (key) => {
|
||||||
if (currentAchKeys.includes(key)) {
|
if (currentAchKeys.includes(key)) {
|
||||||
setValue("achievement_keys", currentAchKeys.filter((k) => k !== key), { shouldDirty: true });
|
setValue("achievement_keys", currentAchKeys.filter((k) => k !== key), { shouldDirty: true });
|
||||||
} else if (currentAchKeys.length < 3) {
|
} else {
|
||||||
setValue("achievement_keys", [...currentAchKeys, key], { shouldDirty: true });
|
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 onSubmit = async (values) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
@@ -151,352 +215,376 @@ export default function AddCourse() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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." />
|
<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">
|
{/* ── Sticky header ── */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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)}>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-xl font-semibold">Add Course</h1>
|
<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>
|
<p className="text-sm text-muted-foreground">Create a new training course.</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
|
||||||
{/* ── Basic Info ── */}
|
{/* ── Step 0: Basic Info ── */}
|
||||||
<SectionCard title="Basic Information">
|
{currentStep === 0 && (
|
||||||
<div className="space-y-1.5">
|
<>
|
||||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
<SectionCard title="Basic Information">
|
||||||
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
|
<div className="space-y-1.5">
|
||||||
<FieldError message={errors.title?.message} />
|
<Label htmlFor="title">
|
||||||
</div>
|
Title <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
<div className="space-y-1.5">
|
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
|
||||||
<Label htmlFor="description">Description</Label>
|
<FieldError message={errors.title?.message} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
<div className="space-y-1.5">
|
||||||
type="button"
|
<Label htmlFor="description">Description</Label>
|
||||||
variant="outline"
|
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
|
||||||
size="sm"
|
</div>
|
||||||
className="w-full mt-1"
|
|
||||||
onClick={() => appendObjective({ text: "" })}
|
<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" />
|
<div className="space-y-2">
|
||||||
Add Objective
|
{objectiveFields.map((field, index) => (
|
||||||
</Button>
|
<div key={field.id} className="flex items-start gap-2">
|
||||||
</div>
|
<div className="flex-1 space-y-1">
|
||||||
</SectionCard>
|
<Input
|
||||||
|
placeholder={`Objective ${index + 1}`}
|
||||||
{/* ── Rewards ── */}
|
{...register(`objectives.${index}.text`)}
|
||||||
<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 }}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<FieldError message={errors.objectives?.[index]?.text?.message} />
|
||||||
</div>
|
</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>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => setAssetPickerOpen(true)}
|
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||||
className="h-7 text-xs"
|
onClick={() => removeObjective(index)}
|
||||||
>
|
>
|
||||||
<ImagePlus className="h-3 w-3 mr-1" />
|
<Trash2 className="h-4 w-4" />
|
||||||
{badgeImageUrl ? "Change" : "Pick from assets"}
|
|
||||||
</Button>
|
</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>
|
|
||||||
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-between"
|
className="w-full mt-1"
|
||||||
disabled={currentAchKeys.length >= 3}
|
onClick={() => appendObjective({ text: "" })}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5">
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
<Trophy className="h-3.5 w-3.5" />
|
Add Objective
|
||||||
{currentAchKeys.length > 0
|
|
||||||
? `${currentAchKeys.length} selected — add more`
|
|
||||||
: "Select achievements"}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</div>
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
</SectionCard>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* ── Actions ── */}
|
{/* ── Step 1: Rewards ── */}
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
{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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
Cancel
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
{currentStep === 0 ? "Cancel" : "Back"}
|
||||||
<Button type="submit" disabled={loading}>
|
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
|
||||||
Create Course
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
|
import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
@@ -204,10 +205,12 @@ export default function CourseAssessment() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { courseId } = useParams();
|
const { courseId } = useParams();
|
||||||
const {
|
const {
|
||||||
fetchAssessment, createAssessment, updateAssessment,
|
createAssessment, updateAssessment,
|
||||||
createAssessmentQuestion, updateAssessmentQuestion,
|
bulkSyncAssessmentQuestions,
|
||||||
course, assessment, loading,
|
course, loading,
|
||||||
} = useCourses();
|
} = useCourses();
|
||||||
|
|
||||||
|
const [localAssessment, setLocalAssessment] = useState(null);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
@@ -236,30 +239,39 @@ export default function CourseAssessment() {
|
|||||||
const navContainerRef = useRef(null);
|
const navContainerRef = useRef(null);
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
|
|
||||||
// ── Fetch ──────────────────────────────────────────────────────────────────
|
// ── Fetch — silently treat 404 as "no assessment yet" (create mode) ─────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
await fetchAssessment(courseId);
|
try {
|
||||||
setInitializing(false);
|
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]);
|
}, [courseId]);
|
||||||
|
|
||||||
// ── Seed ──────────────────────────────────────────────────────────────────
|
// ── Seed ──────────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!assessment) return;
|
if (!localAssessment) return;
|
||||||
const t = assessment.title ?? "";
|
const t = localAssessment.title ?? "";
|
||||||
const ps = assessment.passing_score ?? 70;
|
const ps = localAssessment.passing_score ?? 70;
|
||||||
const tl = assessment.time_limit_minutes ?? "";
|
const tl = localAssessment.time_limit_minutes ?? "";
|
||||||
const ir = assessment.is_required === true || assessment.is_required === 1;
|
const ir = localAssessment.is_required === true || localAssessment.is_required === 1;
|
||||||
const mq = assessment.max_questions ?? "";
|
const mq = localAssessment.max_questions ?? "";
|
||||||
const ma = assessment.max_attempts ?? 3;
|
const ma = localAssessment.max_attempts ?? 3;
|
||||||
const ch = assessment.cooldown_hours ?? 24;
|
const ch = localAssessment.cooldown_hours ?? 24;
|
||||||
const sq = assessment.shuffle_questions === true || assessment.shuffle_questions === 1;
|
const sq = localAssessment.shuffle_questions === true || localAssessment.shuffle_questions === 1;
|
||||||
const qs = (assessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
|
const qs = (localAssessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
|
||||||
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
|
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
|
||||||
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setShuffleQuestions(sq); setQuestions(qs);
|
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 });
|
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 ────────────────────────────────
|
// ── Measure sticky header → --assessment-h ────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -379,7 +391,7 @@ export default function CourseAssessment() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assessmentId = assessment?.assessment_id;
|
const assessmentId = localAssessment?.assessment_id;
|
||||||
const meta = {
|
const meta = {
|
||||||
title: title || "Course Assessment",
|
title: title || "Course Assessment",
|
||||||
passing_score: passingScore,
|
passing_score: passingScore,
|
||||||
@@ -420,18 +432,12 @@ export default function CourseAssessment() {
|
|||||||
const res = await createAssessment(courseId, meta);
|
const res = await createAssessment(courseId, meta);
|
||||||
id = res?.data?.data?.data?.assessment_id;
|
id = res?.data?.data?.data?.assessment_id;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
setLocalAssessment((prev) => ({ ...prev, assessment_id: id }));
|
||||||
} else {
|
} else {
|
||||||
await updateAssessment(courseId, id, meta);
|
await updateAssessment(courseId, id, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < questions.length; i++) {
|
await bulkSyncAssessmentQuestions(courseId, id, questions, user?.user_id);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
|
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,
|
CheckCircle2, Circle, Users, Activity,
|
||||||
ChevronDown, ChevronUp,
|
ChevronDown, ChevronUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -338,27 +340,35 @@ export default function ViewAssessment() {
|
|||||||
const { courseId } = useParams();
|
const { courseId } = useParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchAssessment, assessment,
|
|
||||||
fetchAssessmentCompletions, fetchAssessmentSessions,
|
fetchAssessmentCompletions, fetchAssessmentSessions,
|
||||||
completions, sessions,
|
completions, sessions,
|
||||||
loading,
|
loading,
|
||||||
} = useCourses();
|
} = useCourses();
|
||||||
|
|
||||||
|
const [localAssessment, setLocalAssessment] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState("questions");
|
const [activeTab, setActiveTab] = useState("questions");
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [courseId]);
|
||||||
|
|
||||||
// Lazy-load completions/sessions the first time each tab is opened
|
// Lazy-load completions/sessions the first time each tab is opened
|
||||||
const loadedRef = { completions: false, sessions: false };
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!assessment?.assessment_id) return;
|
if (!localAssessment?.assessment_id) return;
|
||||||
if (activeTab === "completions") fetchAssessmentCompletions(courseId, assessment.assessment_id);
|
if (activeTab === "completions") fetchAssessmentCompletions(courseId, localAssessment.assessment_id);
|
||||||
if (activeTab === "sessions") fetchAssessmentSessions(courseId, assessment.assessment_id);
|
if (activeTab === "sessions") fetchAssessmentSessions(courseId, localAssessment.assessment_id);
|
||||||
}, [activeTab, assessment?.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);
|
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -380,7 +390,7 @@ export default function ViewAssessment() {
|
|||||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
||||||
View Assessment
|
View Assessment
|
||||||
</h1>
|
</h1>
|
||||||
{assessment && (
|
{localAssessment && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
@@ -393,7 +403,7 @@ export default function ViewAssessment() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
{assessment && (
|
{localAssessment && (
|
||||||
<div className="flex gap-1 pb-0 -mb-px">
|
<div className="flex gap-1 pb-0 -mb-px">
|
||||||
{TABS.map(({ key, label, icon: Icon }) => (
|
{TABS.map(({ key, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
@@ -416,9 +426,9 @@ export default function ViewAssessment() {
|
|||||||
{/* ── Content ── */}
|
{/* ── Content ── */}
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
<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">
|
<div className="max-w-3xl mx-auto space-y-5 pb-16">
|
||||||
{loading && !assessment ? (
|
{loading && !localAssessment ? (
|
||||||
<LoadingSkeleton />
|
<LoadingSkeleton />
|
||||||
) : !assessment ? (
|
) : !localAssessment ? (
|
||||||
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
|
<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" />
|
<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>
|
<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">
|
<SectionCard title="Settings">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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">
|
<InfoRow label="Required">
|
||||||
<Badge variant={assessment.is_required ? "default" : "secondary"} className="mt-0.5">
|
<Badge variant={localAssessment.is_required ? "default" : "secondary"} className="mt-0.5">
|
||||||
{assessment.is_required ? "Required" : "Optional"}
|
{localAssessment.is_required ? "Required" : "Optional"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
<InfoRow label="Passing Score">{assessment.passing_score ?? 70}%</InfoRow>
|
<InfoRow label="Passing Score">{localAssessment.passing_score ?? 70}%</InfoRow>
|
||||||
<InfoRow label="Time Limit">
|
<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>
|
||||||
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
|
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
|
||||||
<InfoRow label="Max Shown per Attempt">
|
<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>
|
||||||
<InfoRow label="Total Points">{totalPoints}</InfoRow>
|
<InfoRow label="Total Points">{totalPoints}</InfoRow>
|
||||||
<InfoRow label="Max Failed Attempts">{assessment.max_attempts ?? 3}</InfoRow>
|
<InfoRow label="Max Failed Attempts">{localAssessment.max_attempts ?? 3}</InfoRow>
|
||||||
<InfoRow label="Cooldown After Fails">{assessment.cooldown_hours ?? 24}h</InfoRow>
|
<InfoRow label="Cooldown After Fails">{localAssessment.cooldown_hours ?? 24}h</InfoRow>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useDateFormat } from "@/hooks/useDateFormat";
|
|||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
|
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
|
||||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||||
import { ACHIEVEMENT_REGISTRY } from "@/utils/achievements.data";
|
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 ─────────────────────────────────────────────────────────────────────
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ViewCourse() {
|
export default function ViewCourse() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { courseId } = useParams();
|
const { courseId } = useParams();
|
||||||
const { fetchCourse, course, loading } = useCourses();
|
const { fetchCourse, course, loading } = useCourses();
|
||||||
const { fmtDateTime } = useDateFormat();
|
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState("details");
|
||||||
const [instructors, setInstructors] = useState([]);
|
const [instructors, setInstructors] = useState([]);
|
||||||
const [achievementKeys, setAchievementKeys] = useState([]);
|
const [achievementKeys, setAchievementKeys] = useState([]);
|
||||||
|
const [achievementRegistry, setAchievementRegistry] = useState([]);
|
||||||
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCourse(courseId);
|
fetchCourse(courseId);
|
||||||
|
|
||||||
// Instructors
|
|
||||||
api.get(`/admin/courses/${courseId}/instructors`)
|
api.get(`/admin/courses/${courseId}/instructors`)
|
||||||
.then(({ data }) => setInstructors(data.data?.data ?? data.data ?? []))
|
.then(({ data }) => setInstructors(data.data?.data ?? data.data ?? []))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Achievements
|
|
||||||
api.get(`/admin/courses/${courseId}/achievements`)
|
api.get(`/admin/courses/${courseId}/achievements`)
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
const rows = data?.data?.data ?? [];
|
const rows = data?.data?.data ?? [];
|
||||||
setAchievementKeys(rows.map((r) => r.achievement_key));
|
setAchievementKeys(rows.map((r) => r.achievement_key));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
api.get("/admin/achievements")
|
||||||
|
.then(({ data }) => setAchievementRegistry(data.data ?? []))
|
||||||
|
.catch(() => {});
|
||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
|
|
||||||
// Fresh stream token for the badge image once course loads
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!course?.badge_asset_id) {
|
if (!course?.badge_asset_id) {
|
||||||
setBadgeImageUrl(course?.badge_image_url ?? null);
|
setBadgeImageUrl(course?.badge_image_url ?? null);
|
||||||
@@ -112,269 +336,82 @@ export default function ViewCourse() {
|
|||||||
}, [course?.badge_asset_id, course?.badge_image_url]);
|
}, [course?.badge_asset_id, course?.badge_image_url]);
|
||||||
|
|
||||||
return (
|
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} />
|
<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">
|
{/* ── Sticky header ── */}
|
||||||
{/* ── Header ── */}
|
<div
|
||||||
<div className="flex items-start justify-between gap-3 mb-6">
|
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||||
<div className="flex items-center gap-3">
|
style={{ top: "var(--navbar-h)" }}
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
</Button>
|
<div className="flex items-center gap-3 py-3">
|
||||||
<div>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
<h1 className="text-xl font-semibold">Course Details</h1>
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<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
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{loading && !course ? (
|
{/* Underline tabs */}
|
||||||
<LoadingSkeleton />
|
<div className="flex gap-1 pb-0 -mb-px">
|
||||||
) : !course ? (
|
{TABS.map(({ key, label, icon: Icon }) => (
|
||||||
<div className="text-sm text-muted-foreground">Course not found.</div>
|
<button
|
||||||
) : (
|
key={key}
|
||||||
<div className="space-y-5">
|
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 ── */}
|
{/* ── Content ── */}
|
||||||
<SectionCard icon={BookOpen} title="Basic Information">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||||
<div className="space-y-4">
|
<div className="max-w-3xl mx-auto pb-16">
|
||||||
<InfoRow label="Title">{course.title}</InfoRow>
|
{activeTab === "details" && (
|
||||||
|
<CourseDetailsTab
|
||||||
{course.description && (
|
course={course}
|
||||||
<InfoRow label="Description">
|
loading={loading}
|
||||||
<span className="whitespace-pre-wrap text-sm font-normal text-foreground">
|
instructors={instructors}
|
||||||
{course.description}
|
achievementKeys={achievementKeys}
|
||||||
</span>
|
achievementRegistry={achievementRegistry}
|
||||||
</InfoRow>
|
badgeImageUrl={badgeImageUrl}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{activeTab === "progress" && (
|
||||||
<InfoRow label="Course Code">
|
<CourseReadingProgressList courseId={courseId} />
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function LessonsList() {
|
|||||||
<div className="w-full space-y-6">
|
<div className="w-full space-y-6">
|
||||||
|
|
||||||
{/* ── Unit header ── */}
|
{/* ── 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 */}
|
{/* Title + Status */}
|
||||||
<div>
|
<div>
|
||||||
<h6 className="text-xs tracking-widest mb-1">UNIT</h6>
|
<h6 className="text-xs tracking-widest mb-1">UNIT</h6>
|
||||||
|
|||||||
@@ -70,21 +70,21 @@ export default function ViewLesson() {
|
|||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<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="text-xs text-muted-foreground uppercase tracking-wide font-medium">Order</p>
|
||||||
<p className="font-semibold text-sm">#{lesson?.order_index ?? 0}</p>
|
<p className="font-semibold text-sm">#{lesson?.order_index ?? 0}</p>
|
||||||
</div>
|
</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">
|
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" /> Duration
|
<Clock className="h-3 w-3" /> Duration
|
||||||
</p>
|
</p>
|
||||||
<p className="font-semibold text-sm">{lesson?.duration_formatted ?? "0 mins"}</p>
|
<p className="font-semibold text-sm">{lesson?.duration_formatted ?? "0 mins"}</p>
|
||||||
</div>
|
</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="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
|
||||||
<p className="font-semibold text-sm">{fmtDate(lesson?.createdAt)}</p>
|
<p className="font-semibold text-sm">{fmtDate(lesson?.createdAt)}</p>
|
||||||
</div>
|
</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="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
|
||||||
<p className="font-semibold text-sm">{fmtDate(lesson?.updatedAt)}</p>
|
<p className="font-semibold text-sm">{fmtDate(lesson?.updatedAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown } from "lucide-react";
|
import { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -196,10 +198,12 @@ export default function ModifyQuiz() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { courseId, unitId } = useParams();
|
const { courseId, unitId } = useParams();
|
||||||
const {
|
const {
|
||||||
fetchQuiz, createQuiz, updateQuiz,
|
createQuiz, updateQuiz,
|
||||||
createQuizQuestion, updateQuizQuestion,
|
bulkSyncQuizQuestions,
|
||||||
course, unit, quiz, loading,
|
course, unit, loading,
|
||||||
} = useCourses();
|
} = useCourses();
|
||||||
|
|
||||||
|
const [localQuiz, setLocalQuiz] = useState(null);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
@@ -227,26 +231,36 @@ export default function ModifyQuiz() {
|
|||||||
{ label: "Quiz" },
|
{ label: "Quiz" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Fetch ──────────────────────────────────────────────────────────────────
|
// ── Fetch — silently treat 404 as "no quiz yet" (create mode) ─────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
await fetchQuiz(courseId, unitId);
|
try {
|
||||||
setInitializing(false);
|
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]);
|
}, [courseId, unitId]);
|
||||||
|
|
||||||
// ── Seed ──────────────────────────────────────────────────────────────────
|
// ── Seed form from fetched quiz ────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quiz) return;
|
if (!localQuiz) return;
|
||||||
const t = quiz.title ?? "";
|
const t = localQuiz.title ?? "";
|
||||||
const ps = quiz.passing_score ?? 70;
|
const ps = localQuiz.passing_score ?? 70;
|
||||||
const ir = quiz.is_required === true || quiz.is_required === 1;
|
const ir = localQuiz.is_required === true || localQuiz.is_required === 1;
|
||||||
const mq = quiz.max_questions ?? "";
|
const mq = localQuiz.max_questions ?? "";
|
||||||
const sq = quiz.shuffle_questions === true || quiz.shuffle_questions === 1;
|
const sq = localQuiz.shuffle_questions === true || localQuiz.shuffle_questions === 1;
|
||||||
const qs = (quiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
|
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);
|
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 });
|
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, shuffleQuestions: sq, questions: qs });
|
||||||
}, [quiz]);
|
}, [localQuiz]);
|
||||||
|
|
||||||
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
|
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -366,7 +380,7 @@ export default function ModifyQuiz() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let quizId = quiz?.quiz_id;
|
let quizId = localQuiz?.quiz_id;
|
||||||
const meta = {
|
const meta = {
|
||||||
title: title || "Unit Quiz",
|
title: title || "Unit Quiz",
|
||||||
passing_score: passingScore,
|
passing_score: passingScore,
|
||||||
@@ -381,18 +395,12 @@ export default function ModifyQuiz() {
|
|||||||
const res = await createQuiz(courseId, unitId, meta);
|
const res = await createQuiz(courseId, unitId, meta);
|
||||||
quizId = res?.data?.data?.data?.quiz_id;
|
quizId = res?.data?.data?.data?.quiz_id;
|
||||||
if (!quizId) return;
|
if (!quizId) return;
|
||||||
|
setLocalQuiz((prev) => ({ ...prev, quiz_id: quizId }));
|
||||||
} else {
|
} else {
|
||||||
await updateQuiz(courseId, unitId, quizId, meta);
|
await updateQuiz(courseId, unitId, quizId, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < questions.length; i++) {
|
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
|
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function UnitsList() {
|
|||||||
<div className="w-full space-y-6">
|
<div className="w-full space-y-6">
|
||||||
|
|
||||||
{/* ── Course header ── */}
|
{/* ── 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 */}
|
{/* Title + Status */}
|
||||||
<div>
|
<div>
|
||||||
<h6 className="text-xs tracking-widest mb-1">COURSE</h6>
|
<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 { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||||
import GroupMultiSelect from '@/components/generic/GroupMultiSelect';
|
import GroupMultiSelect from '@/components/generic/GroupMultiSelect';
|
||||||
|
import api from '@/utils/api.util';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
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() {
|
export default function CreateTaskList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createTaskList, assignGroups, loading } = useAdminTask();
|
const { createTaskList, assignGroups, loading } = useAdminTask();
|
||||||
|
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
const [form, setForm] = useState({ name: '', description: '' });
|
const [form, setForm] = useState({ name: '', description: '' });
|
||||||
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
|
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
|
||||||
|
const [allGroups, setAllGroups] = useState([]);
|
||||||
const [errors, setErrors] = 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 = {};
|
const e = {};
|
||||||
if (!form.name.trim()) e.name = 'Task list name is required.';
|
if (!form.name.trim()) e.name = 'Task list name is required.';
|
||||||
setErrors(e);
|
setErrors(e);
|
||||||
return Object.keys(e).length === 0;
|
return Object.keys(e).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleNext = () => {
|
||||||
e.preventDefault();
|
if (step === 0 && !validateDetails()) return;
|
||||||
if (!validate()) 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({
|
const created = await createTaskList({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@@ -45,10 +84,17 @@ export default function CreateTaskList() {
|
|||||||
navigate(`/admin/taskList/${created.task_list_id}/view`);
|
navigate(`/admin/taskList/${created.task_list_id}/view`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedGroupNames = allGroups
|
||||||
|
.filter((g) => selectedGroupIds.includes(g.group_id))
|
||||||
|
.map((g) => g.name);
|
||||||
|
|
||||||
return (
|
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="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||||
<div className="mx-auto">
|
<div className="mx-auto w-full lg:w-2xl space-y-6">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate('/admin/taskList')}>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate('/admin/taskList')}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -57,37 +103,80 @@ export default function CreateTaskList() {
|
|||||||
<p className="text-sm text-muted-foreground">View course information.</p>
|
<p className="text-sm text-muted-foreground">View course information.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Card className="lg:w-2xl">
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
|
|
||||||
{/* Name */}
|
{/* Stepper */}
|
||||||
<div className="space-y-3">
|
<div className="flex items-center gap-0">
|
||||||
<Label htmlFor="name">Name *</Label>
|
{STEPS.map((s, i) => {
|
||||||
<Input
|
const Icon = s.icon;
|
||||||
id="name"
|
const isActive = step === i;
|
||||||
value={form.name}
|
const isDone = step > i;
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
||||||
placeholder="e.g. Onboarding Tasks"
|
return (
|
||||||
/>
|
<div key={s.id} className="flex items-center flex-1 last:flex-none">
|
||||||
{errors.name && (
|
<div className="flex flex-col items-center gap-1">
|
||||||
<p className="text-xs text-destructive">{errors.name}</p>
|
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Step content */}
|
||||||
<div className="space-y-3">
|
<Card>
|
||||||
<Label htmlFor="description">Description</Label>
|
<CardContent className="space-y-4 min-h-[280px]">
|
||||||
<Textarea
|
<h2 className="text-base font-medium">{STEPS[step].label}</h2>
|
||||||
id="description"
|
|
||||||
value={form.description}
|
{/* ── Step 1: Details ── */}
|
||||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
{step === 0 && (
|
||||||
placeholder="Optional description"
|
<div className="space-y-4">
|
||||||
rows={3}
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Groups */}
|
{/* ── Step 2: Assign Groups ── */}
|
||||||
|
{step === 1 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>
|
<Label>
|
||||||
Assign to Groups
|
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.
|
Members of selected groups will be able to see and complete this task list.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* ── Step 3: Review ── */}
|
||||||
<div className="flex gap-2 justify-end pt-2">
|
{step === 2 && (
|
||||||
<Button
|
<div className="space-y-4">
|
||||||
type="button"
|
<div className="border border-border rounded-lg p-4 space-y-1">
|
||||||
variant="outline"
|
<div className="flex items-center gap-2 mb-2">
|
||||||
onClick={() => navigate('/admin/taskList')}
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
>
|
<span className="text-sm font-medium">Task List Details</span>
|
||||||
Cancel
|
</div>
|
||||||
</Button>
|
<SummaryRow label="Name" value={form.name || '—'} />
|
||||||
<Button type="submit" disabled={loading}>
|
<SummaryRow label="Description" value={form.description || '—'} />
|
||||||
{loading ? 'Creating…' : 'Create Task List'}
|
</div>
|
||||||
</Button>
|
|
||||||
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { format, parseISO, isValid } from 'date-fns';
|
||||||
|
|
||||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import RequirementBuilder from './RequirementBuilder';
|
import RequirementBuilder from './RequirementBuilder';
|
||||||
|
|
||||||
@@ -9,17 +12,74 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, ListChecks, ClipboardList, Link as LinkIcon, Upload, BookOpen, Layers, Clock } from 'lucide-react';
|
||||||
import DeadlinePicker from '@/components/generic/DeadlinePicker';
|
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() {
|
export default function CreateTask() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { taskListId } = useParams();
|
const { taskListId } = useParams();
|
||||||
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
|
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
|
||||||
|
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -28,7 +88,7 @@ export default function CreateTask() {
|
|||||||
});
|
});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [courses, setCourses] = useState([]);
|
const [courses, setCourses] = useState([]);
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
const [lessons, setLessons] = useState([]);
|
const [lessons, setLessons] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,30 +97,71 @@ export default function CreateTask() {
|
|||||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const validate = () => {
|
const validateStep = (s) => {
|
||||||
const e = {};
|
const schema = s === 0 ? taskSchema.pick({ name: true }) : taskSchema.pick({ requirements: true });
|
||||||
if (!form.name.trim()) e.name = 'Task name is required.';
|
const result = schema.safeParse(form);
|
||||||
setErrors(e);
|
if (!result.success) {
|
||||||
return Object.keys(e).length === 0;
|
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) => {
|
const handleNext = () => {
|
||||||
e.preventDefault();
|
if (!validateStep(step)) return;
|
||||||
if (!validate()) 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, {
|
const created = await createTask(taskListId, {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
deadline: form.deadline || null,
|
deadline: form.deadline || null,
|
||||||
requirements: form.requirements,
|
// 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 (
|
return (
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
|
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
@@ -68,9 +169,46 @@ export default function CreateTask() {
|
|||||||
<h1 className="text-xl font-semibold">Create Task</h1>
|
<h1 className="text-xl font-semibold">Create Task</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
{/* Stepper */}
|
||||||
{/* Task info */}
|
<div className="flex items-center gap-0">
|
||||||
<Card className="lg:w-2xl">
|
{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>
|
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -95,7 +233,6 @@ export default function CreateTask() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Deadline — date popover + time input */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Deadline</Label>
|
<Label>Deadline</Label>
|
||||||
<DeadlinePicker
|
<DeadlinePicker
|
||||||
@@ -104,11 +241,12 @@ export default function CreateTask() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Requirements */}
|
{/* ── Step 2: Requirements ── */}
|
||||||
|
{step === 1 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Requirements</CardTitle>
|
<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.
|
Define what a user needs to do to complete this task.
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-2">
|
||||||
<RequirementBuilder
|
<RequirementBuilder
|
||||||
value={form.requirements}
|
value={form.requirements}
|
||||||
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
|
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
|
||||||
@@ -124,19 +262,86 @@ export default function CreateTask() {
|
|||||||
units={units}
|
units={units}
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
/>
|
/>
|
||||||
|
{errors.requirements && (
|
||||||
|
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
{/* ── Step 3: Review ── */}
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(`/admin/taskList/${taskListId}/tasks`)}>
|
{step === 2 && (
|
||||||
Cancel
|
<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>
|
||||||
<Button type="submit" disabled={loading}>
|
) : (
|
||||||
|
<Button type="button" disabled={loading} onClick={handleCreate}>
|
||||||
{loading ? 'Creating…' : 'Create Task'}
|
{loading ? 'Creating…' : 'Create Task'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
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 = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: 'pending', label: 'Pending' },
|
{ value: 'pending', label: 'Pending' },
|
||||||
{ value: 'in_progress', label: 'In Progress' },
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
@@ -31,10 +59,14 @@ export default function EditTask() {
|
|||||||
const [courses, setCourses] = useState([]);
|
const [courses, setCourses] = useState([]);
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
const [lessons, setLessons] = useState([]);
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const initialRequirementsRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTask(taskListId, taskId).then((data) => {
|
fetchTask(taskListId, taskId).then((data) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
const reqs = data.requirements ?? [];
|
||||||
|
initialRequirementsRef.current = JSON.stringify(reqs);
|
||||||
setForm({
|
setForm({
|
||||||
name: data.name ?? '',
|
name: data.name ?? '',
|
||||||
description: data.description ?? '',
|
description: data.description ?? '',
|
||||||
@@ -42,7 +74,7 @@ export default function EditTask() {
|
|||||||
? new Date(data.deadline).toISOString().slice(0, 16)
|
? new Date(data.deadline).toISOString().slice(0, 16)
|
||||||
: '',
|
: '',
|
||||||
status: data.status ?? 'pending',
|
status: data.status ?? 'pending',
|
||||||
requirements: data.requirements ?? [],
|
requirements: reqs,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
fetchCoursesFlat().then((d) => d && setCourses(d));
|
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||||
@@ -50,26 +82,49 @@ export default function EditTask() {
|
|||||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||||
}, [taskListId, taskId]);
|
}, [taskListId, taskId]);
|
||||||
|
|
||||||
|
const requirementsChanged = () =>
|
||||||
|
JSON.stringify(form?.requirements ?? []) !== initialRequirementsRef.current;
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const e = {};
|
const result = taskSchema.safeParse(form ?? {});
|
||||||
if (!form?.name?.trim()) e.name = 'Task name is required.';
|
if (!result.success) {
|
||||||
setErrors(e);
|
const e = {};
|
||||||
return Object.keys(e).length === 0;
|
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();
|
e.preventDefault();
|
||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
|
if (requirementsChanged()) {
|
||||||
const updated = await updateTask(taskListId, taskId, {
|
setConfirmOpen(true);
|
||||||
name: form.name.trim(),
|
} else {
|
||||||
description: form.description.trim() || null,
|
doSave();
|
||||||
deadline: form.deadline || null,
|
}
|
||||||
status: form.status,
|
|
||||||
requirements: form.requirements,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/view`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!form) return (
|
if (!form) return (
|
||||||
@@ -81,6 +136,33 @@ export default function EditTask() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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="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 space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -150,7 +232,7 @@ export default function EditTask() {
|
|||||||
<CardTitle className="text-base">Requirements</CardTitle>
|
<CardTitle className="text-base">Requirements</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">Changes here will replace existing requirements.</p>
|
<p className="text-sm text-muted-foreground">Changes here will replace existing requirements.</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-2">
|
||||||
<RequirementBuilder
|
<RequirementBuilder
|
||||||
value={form.requirements}
|
value={form.requirements}
|
||||||
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
|
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
|
||||||
@@ -158,6 +240,9 @@ export default function EditTask() {
|
|||||||
units={units}
|
units={units}
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
/>
|
/>
|
||||||
|
{errors.requirements && (
|
||||||
|
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -173,5 +258,6 @@ export default function EditTask() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Check } from 'lucide-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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { resolveTierBadge } from '@/utils/tierBadge.util';
|
||||||
|
import api from '@/utils/api.util';
|
||||||
|
|
||||||
// ─── Requirement type config ──────────────────────────────────────────────────
|
// ─── Requirement type config ──────────────────────────────────────────────────
|
||||||
const REQUIREMENT_TYPES = [
|
const REQUIREMENT_TYPES = [
|
||||||
@@ -34,54 +34,90 @@ const FILE_TYPE_OPTIONS = [
|
|||||||
{ value: 'zip', label: 'ZIP' },
|
{ value: 'zip', label: 'ZIP' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Searchable content picker (Popover + Command + ScrollArea) ───────────────
|
// ─── Duration formatter ───────────────────────────────────────────────────────
|
||||||
// renderItem — optional custom JSX per item (defaults to o[labelKey])
|
function fmtDuration(seconds) {
|
||||||
// searchKey — optional key whose value cmdk uses for filtering (defaults to labelKey)
|
if (!seconds || seconds <= 0) return null;
|
||||||
function ContentPicker({ value, options, idKey, labelKey, searchKey, placeholder = 'Select…', onSelect, renderItem, listHeight = 'h-48' }) {
|
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 [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 selected = options.find((o) => String(o[idKey]) === String(value));
|
||||||
|
|
||||||
|
const handleOpenChange = (v) => {
|
||||||
|
setOpen(v);
|
||||||
|
if (!v) setQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
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
|
? renderTrigger(selected)
|
||||||
? selected[labelKey]
|
: <span className="text-muted-foreground">{placeholder}</span>}
|
||||||
: <span className="text-muted-foreground">{placeholder}</span>}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
<PopoverContent className="p-0" style={{ width: 'var(--radix-popover-trigger-width)' }} align="start">
|
||||||
<Command>
|
<div className="flex items-center gap-2 border-b px-3">
|
||||||
<CommandInput placeholder="Search…" />
|
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<CommandList className="max-h-none overflow-visible">
|
<input
|
||||||
<ScrollArea className={listHeight}>
|
autoFocus
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
value={query}
|
||||||
<CommandGroup>
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
{options.map((o) => (
|
placeholder="Search…"
|
||||||
<CommandItem
|
className="flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
key={o[idKey]}
|
/>
|
||||||
value={searchKey ? o[searchKey] : o[labelKey]}
|
</div>
|
||||||
onSelect={() => {
|
<div className={`overflow-y-auto ${listHeight}`}>
|
||||||
onSelect(o);
|
{filtered.length === 0 ? (
|
||||||
setOpen(false);
|
<p className="py-6 text-center text-sm text-muted-foreground">No results found.</p>
|
||||||
}}
|
) : (
|
||||||
>
|
filtered.map((o) => (
|
||||||
{renderItem ? renderItem(o) : o[labelKey]}
|
<div
|
||||||
<Check className={cn('ml-auto h-4 w-4 shrink-0', String(value) === String(o[idKey]) ? 'opacity-100' : 'opacity-0')} />
|
key={o[idKey]}
|
||||||
</CommandItem>
|
role="option"
|
||||||
))}
|
aria-selected={String(value) === String(o[idKey])}
|
||||||
</CommandGroup>
|
className={`cursor-pointer select-none transition-colors hover:bg-accent hover:text-accent-foreground${String(value) === String(o[idKey]) ? ' bg-accent/50' : ''}`}
|
||||||
</ScrollArea>
|
onClick={() => { onSelect(o); setOpen(false); setQuery(''); }}
|
||||||
</CommandList>
|
>
|
||||||
</Command>
|
{renderItem(o)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
@@ -92,13 +128,10 @@ function createRequirement(type = 'visit_link') {
|
|||||||
return {
|
return {
|
||||||
_key: crypto.randomUUID(),
|
_key: crypto.randomUUID(),
|
||||||
type,
|
type,
|
||||||
// visit_link
|
|
||||||
link_url: '',
|
link_url: '',
|
||||||
link_label: '',
|
link_label: '',
|
||||||
// upload_file
|
|
||||||
allowed_file_types: [],
|
allowed_file_types: [],
|
||||||
max_file_count: 1,
|
max_file_count: 1,
|
||||||
// read_*
|
|
||||||
reference_id: '',
|
reference_id: '',
|
||||||
reference_label: '',
|
reference_label: '',
|
||||||
};
|
};
|
||||||
@@ -111,17 +144,28 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
|
? 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) => {
|
const emit = (next) => {
|
||||||
setItems(next);
|
setItems(next);
|
||||||
// strip _key before calling onChange
|
|
||||||
onChange?.(next.map(({ _key, ...r }) => r));
|
onChange?.(next.map(({ _key, ...r }) => r));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addItem = () => emit([...items, createRequirement('visit_link')]);
|
const addItem = () => emit([...items, createRequirement('visit_link')]);
|
||||||
|
|
||||||
const removeItem = (key) => emit(items.filter((i) => i._key !== key));
|
const removeItem = (key) => emit(items.filter((i) => i._key !== key));
|
||||||
|
|
||||||
const updateItem = (key, patch) =>
|
const updateItem = (key, patch) =>
|
||||||
emit(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
|
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 });
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
@@ -158,7 +216,6 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
{idx + 1}
|
{idx + 1}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{/* Type selector */}
|
|
||||||
<Select
|
<Select
|
||||||
value={item.type}
|
value={item.type}
|
||||||
onValueChange={(v) => updateItem(item._key, { type: v })}
|
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 fields ── */}
|
||||||
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
|
{['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">
|
<Label className="text-xs">
|
||||||
{item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
|
{item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
{/* Reference picker */}
|
{/* ── read_course picker ── */}
|
||||||
{item.type === 'read_course' && (
|
{item.type === 'read_course' && (
|
||||||
<ContentPicker
|
<ContentPicker
|
||||||
value={item.reference_id}
|
value={item.reference_id}
|
||||||
@@ -260,10 +317,33 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
idKey="uuid"
|
idKey="uuid"
|
||||||
labelKey="title"
|
labelKey="title"
|
||||||
placeholder="Select a course"
|
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' && (
|
{item.type === 'read_unit' && (
|
||||||
<ContentPicker
|
<ContentPicker
|
||||||
value={item.reference_id}
|
value={item.reference_id}
|
||||||
@@ -272,18 +352,44 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
labelKey="title"
|
labelKey="title"
|
||||||
searchKey="_search"
|
searchKey="_search"
|
||||||
placeholder="Select a unit"
|
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) => (
|
renderItem={(u) => (
|
||||||
<div className="flex flex-col gap-0.5 py-0.5 min-w-0">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
<span className="text-xs text-muted-foreground leading-tight truncate">
|
<TierBadge subscription={u.subscription} tierMap={tierMap} />
|
||||||
{u.course_title} · Unit {u.order_index + 1}
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
</span>
|
|
||||||
<span className="text-sm leading-tight truncate">{u.title}</span>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
onSelect={(u) => updateItem(item._key, { reference_id: u.uuid, reference_label: u.title })}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── read_lesson picker ── */}
|
||||||
{item.type === 'read_lesson' && (
|
{item.type === 'read_lesson' && (
|
||||||
<ContentPicker
|
<ContentPicker
|
||||||
value={item.reference_id}
|
value={item.reference_id}
|
||||||
@@ -292,22 +398,50 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
labelKey="title"
|
labelKey="title"
|
||||||
searchKey="_search"
|
searchKey="_search"
|
||||||
placeholder="Select a lesson"
|
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) => (
|
renderItem={(l) => (
|
||||||
<div className="flex flex-col gap-0.5 py-0.5 min-w-0">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
<span className="text-xs text-muted-foreground leading-tight truncate">
|
<TierBadge subscription={l.subscription} tierMap={tierMap} />
|
||||||
{l.course_title}
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<span className="mx-1 opacity-50">›</span>
|
<span className="text-sm leading-tight text-muted-foreground truncate">
|
||||||
Unit {l.unit_order + 1}
|
{l.course_title} (Course) | Unit {l.unit_order + 1} | Lesson {l.order_index + 1}
|
||||||
<span className="mx-1 opacity-50">›</span>
|
</span>
|
||||||
Lesson {l.order_index + 1}
|
<span className="text-sm leading-tight truncate">{l.title}</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="text-sm leading-tight truncate">{l.title}</span>
|
{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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -319,6 +453,49 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Requirement
|
Add Requirement
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} 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 AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
||||||
import { formatDate } from '@/utils/table.util';
|
import { formatDate } from '@/utils/table.util';
|
||||||
|
|
||||||
@@ -245,7 +251,7 @@ export default function Tasks() {
|
|||||||
|
|
||||||
{/* ── All Groups Dialog ─────────────────────────────────────────── */}
|
{/* ── All Groups Dialog ─────────────────────────────────────────── */}
|
||||||
<Dialog open={showGroupsDialog} onOpenChange={setShowGroupsDialog}>
|
<Dialog open={showGroupsDialog} onOpenChange={setShowGroupsDialog}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
@@ -267,16 +273,43 @@ export default function Tasks() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* ── Single archive ────────────────────────────────────────────── */}
|
{/* ── Single archive ────────────────────────────────────────────── */}
|
||||||
<ArchiveDialog
|
<AlertDialog open={!!archiveTarget} onOpenChange={(v) => !v && setArchiveTarget(null)}>
|
||||||
open={!!archiveTarget}
|
<AlertDialogContent className="sm:max-w-sm">
|
||||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
<AlertDialogHeader>
|
||||||
entity={archiveTarget}
|
<AlertDialogTitle>Archive Task</AlertDialogTitle>
|
||||||
entityLabel="Task"
|
<AlertDialogDescription asChild>
|
||||||
getName={(r) => r?.name}
|
<div className="space-y-3">
|
||||||
onArchive={(entity) => archiveTask(taskListId, entity?.task_id)}
|
<p>
|
||||||
loading={loading}
|
Are you sure you want to archive{' '}
|
||||||
onSuccess={afterMutation}
|
<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 ────────────────────────────────────────────── */}
|
{/* ── Single restore ────────────────────────────────────────────── */}
|
||||||
<RestoreDialog
|
<RestoreDialog
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/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 { useTiers } from "@/contexts/AdminTiersContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||||
|
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
@@ -26,6 +27,7 @@ const DURATION_UNITS = [
|
|||||||
{ value: "year", label: "Year(s)" },
|
{ value: "year", label: "Year(s)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const DURATION_UNIT_LIMITS = {
|
const DURATION_UNIT_LIMITS = {
|
||||||
minute: { max: 59, nextLabel: "Hour(s)", factor: 60 },
|
minute: { max: 59, nextLabel: "Hour(s)", factor: 60 },
|
||||||
hour: { max: 23, nextLabel: "Day(s)", factor: 24 },
|
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 ─────────────────────────────────────────────────────────────────────
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AddPlan() {
|
export default function AddPlan() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createPlan, loading } = useTiers();
|
const { createPlan, loading } = useTiers();
|
||||||
|
|
||||||
const [categories, setCategories] = useState([]);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [catLoading, setCatLoading] = useState(true);
|
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 [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||||
|
const [courseConflicts, setCourseConflicts] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/admin/tiers/categories")
|
api.get("/admin/tiers/categories")
|
||||||
.then(({ data }) => setCategories((data.data ?? []).filter((c) => !c.is_default && c.is_active)))
|
.then(({ data }) => setCategories((data.data ?? []).filter((c) => !c.is_default && c.is_active)))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setCatLoading(false));
|
.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),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { tier_category_id: "", label: "", description: "", duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
|
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
|
// Reset picker when category changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedCourseIds(new Set());
|
setSelectedCourseIds(new Set());
|
||||||
|
setCourseConflicts(0);
|
||||||
}, [categorySlug]);
|
}, [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 onSubmit = async (values) => {
|
||||||
const result = await createPlan(values);
|
const result = await createPlan(values);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
@@ -139,115 +230,151 @@ export default function AddPlan() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator steps={STEPS} current={currentStep} maxStepReached={maxStepReached} onStepClick={handleStepClick} />
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
<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">
|
<div className="space-y-1.5">
|
||||||
<Label>Tier Category <span className="text-destructive">*</span></Label>
|
<Label>Tier Category <span className="text-destructive">*</span></Label>
|
||||||
{catLoading ? (
|
{catLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||||
<Spinner className="h-4 w-4" /> Loading categories…
|
<Spinner className="h-4 w-4" /> Loading categories…
|
||||||
</div>
|
</div>
|
||||||
) : categories.length === 0 ? (
|
) : categories.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground py-2">
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
No tier categories found.{" "}
|
No tier categories found.{" "}
|
||||||
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
|
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
value={selectedCategoryId}
|
value={selectedCategoryId}
|
||||||
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
|
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select tier category" />
|
<SelectValue placeholder="Select tier category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
|
<SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
|
||||||
{c.name}
|
{c.name}
|
||||||
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
|
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<FieldError message={errors.tier_category_id?.message} />
|
<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>
|
</div>
|
||||||
<FieldError message={errors.duration_value?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
|
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
||||||
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
|
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
|
||||||
<FieldError message={errors.price?.message} />
|
<FieldError message={errors.label?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="currency">Currency</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Input id="currency" maxLength={3} placeholder="USD" {...register("currency")} />
|
<Textarea
|
||||||
<FieldError message={errors.currency?.message} />
|
id="description"
|
||||||
</div>
|
placeholder="Brief description shown to users on the plans page."
|
||||||
</SectionCard>
|
rows={3}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.description?.message} />
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{categorySlug && (
|
{/* ── Step 1: Duration & Pricing ── */}
|
||||||
<SectionCard title="Assigned Courses">
|
{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
|
<CoursePicker
|
||||||
subscription={categorySlug}
|
subscription={categorySlug}
|
||||||
selectedIds={selectedCourseIds}
|
selectedIds={selectedCourseIds}
|
||||||
onChange={setSelectedCourseIds}
|
onChange={setSelectedCourseIds}
|
||||||
|
onConflictsChange={setCourseConflicts}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
<Button
|
||||||
<Button type="submit" disabled={loading || catLoading || !selectedCategoryId}>
|
type="button"
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
variant="outline"
|
||||||
Create Plan
|
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{currentStep === 0 ? "Cancel" : "Back"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||||
|
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
const DURATION_UNITS = [
|
const DURATION_UNITS = [
|
||||||
@@ -27,6 +28,7 @@ const DURATION_UNITS = [
|
|||||||
{ value: "year", label: "Year(s)" },
|
{ value: "year", label: "Year(s)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
|
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
|
||||||
|
|
||||||
function durationDaysToValue(days, unit) {
|
function durationDaysToValue(days, unit) {
|
||||||
@@ -82,6 +84,8 @@ export default function EditPlan() {
|
|||||||
|
|
||||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||||
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
||||||
|
const [courseConflicts, setCourseConflicts] = useState(0);
|
||||||
|
const [currencies, setCurrencies] = useState([]);
|
||||||
const [impactDialog, setImpactDialog] = useState(false);
|
const [impactDialog, setImpactDialog] = useState(false);
|
||||||
const [impactCount, setImpactCount] = useState(0);
|
const [impactCount, setImpactCount] = useState(0);
|
||||||
const [impactLoading, setImpactLoading] = useState(false);
|
const [impactLoading, setImpactLoading] = useState(false);
|
||||||
@@ -93,6 +97,9 @@ export default function EditPlan() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlan(planId);
|
fetchPlan(planId);
|
||||||
|
api.get("/admin/tiers/currencies")
|
||||||
|
.then(({ data }) => setCurrencies(data.data ?? []))
|
||||||
|
.catch(() => {});
|
||||||
}, [planId]);
|
}, [planId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -250,8 +257,12 @@ export default function EditPlan() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="currency">Currency</Label>
|
<Label>Currency</Label>
|
||||||
<Input id="currency" maxLength={3} {...register("currency")} />
|
<CurrencyPicker
|
||||||
|
value={watch("currency") ?? "USD"}
|
||||||
|
currencies={currencies}
|
||||||
|
onValueChange={(v) => setValue("currency", v, { shouldDirty: true })}
|
||||||
|
/>
|
||||||
<FieldError message={errors.currency?.message} />
|
<FieldError message={errors.currency?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -275,6 +286,8 @@ export default function EditPlan() {
|
|||||||
selectedIds={selectedCourseIds}
|
selectedIds={selectedCourseIds}
|
||||||
onChange={setSelectedCourseIds}
|
onChange={setSelectedCourseIds}
|
||||||
isPreloaded={true}
|
isPreloaded={true}
|
||||||
|
currentPlanId={planId}
|
||||||
|
onConflictsChange={setCourseConflicts}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -290,7 +303,7 @@ export default function EditPlan() {
|
|||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading || impactLoading}>Cancel</Button>
|
<Button type="button" variant="outline" onClick={() => navigate(-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" />}
|
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import * as LucideIcons from "lucide-react";
|
|||||||
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
|
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
const BADGE_ICON_OPTIONS = [
|
export const BADGE_ICON_OPTIONS = [
|
||||||
// Prestige / rank
|
// Prestige / rank
|
||||||
{ name: "ShieldCheck", icon: ShieldCheck },
|
{ name: "ShieldCheck", icon: ShieldCheck },
|
||||||
{ name: "Shield", icon: Shield },
|
{ 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 { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
@@ -63,9 +63,6 @@ export default function PaymentPolicy() {
|
|||||||
const [addForm, setAddForm] = useState(EMPTY_PROMO);
|
const [addForm, setAddForm] = useState(EMPTY_PROMO);
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
// ── Localized prices (for notice in promo section)
|
|
||||||
const [localizedPrices, setLocalizedPrices] = useState([]);
|
|
||||||
|
|
||||||
// ─── Load ────────────────────────────────────────────────────────────────────
|
// ─── Load ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -87,9 +84,6 @@ export default function PaymentPolicy() {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setPolicyLoading(false));
|
.finally(() => setPolicyLoading(false));
|
||||||
|
|
||||||
api.get(`/admin/tiers/${planId}/prices`)
|
|
||||||
.then(({ data }) => setLocalizedPrices(data.data ?? []))
|
|
||||||
.catch(() => {});
|
|
||||||
}, [planId]);
|
}, [planId]);
|
||||||
|
|
||||||
// ─── Save ────────────────────────────────────────────────────────────────────
|
// ─── Save ────────────────────────────────────────────────────────────────────
|
||||||
@@ -182,24 +176,6 @@ export default function PaymentPolicy() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<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 ─────────────────────────────────────────── */}
|
{/* ── Refund Policy ─────────────────────────────────────────── */}
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
@@ -262,30 +238,6 @@ export default function PaymentPolicy() {
|
|||||||
title="Promo Codes"
|
title="Promo Codes"
|
||||||
description="Define discount codes users can apply at checkout. Flat reduces price by a fixed amount; percent reduces by a percentage."
|
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 */}
|
{/* Existing rules */}
|
||||||
{promoRules.length > 0 ? (
|
{promoRules.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -25,54 +25,7 @@ export default function PlanList() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<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 />
|
<TierPlansTable />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, BookOpen, Clock } from "lucide-react";
|
import {
|
||||||
import { Badge } from "@/components/ui/badge";
|
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
|
||||||
import { Button } from "@/components/ui/button";
|
ShieldCheck, Plus, Trash2, Loader2, Receipt,
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
} 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 { 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 { useTiers } from "@/contexts/AdminTiersContext";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.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 }) {
|
function InfoRow({ label, children }) {
|
||||||
return (
|
return (
|
||||||
@@ -25,12 +34,15 @@ function InfoRow({ label, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionCard({ icon: Icon, title, children }) {
|
function SectionCard({ icon: Icon, title, description, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-sm font-semibold">{title}</h2>
|
{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>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
{children}
|
{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) {
|
function formatDuration(days, unit) {
|
||||||
if (!days) return `${days} days`;
|
if (!days) return `${days} days`;
|
||||||
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
|
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
|
||||||
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
|
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
|
||||||
const value = Math.round((days / multiplier) * 1000) / 1000;
|
const value = Math.round((days / multiplier) * 1000) / 1000;
|
||||||
const label = unit ?? 'day';
|
const label = unit ?? "day";
|
||||||
return `${value} ${label}${value !== 1 ? 's' : ''}`;
|
return `${value} ${label}${value !== 1 ? "s" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCourseDuration(seconds = 0) {
|
function formatCourseDuration(seconds = 0) {
|
||||||
@@ -65,17 +68,452 @@ function formatCourseDuration(seconds = 0) {
|
|||||||
return `${m}m`;
|
return `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ViewPlan() {
|
// ─── Tab: Plan Details ─────────────────────────────────────────────────────────
|
||||||
const navigate = useNavigate();
|
|
||||||
const { planId } = useParams();
|
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading }) {
|
||||||
const { fetchPlan, plan, loading } = useTiers();
|
|
||||||
const { fmtDateTime } = useDateFormat();
|
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 [tierCategories, setTierCategories] = useState([]);
|
||||||
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
|
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
|
||||||
|
|
||||||
const [assignedCourses, setAssignedCourses] = useState([]);
|
const [assignedCourses, setAssignedCourses] = useState([]);
|
||||||
const [coursesLoading, setCoursesLoading] = useState(false);
|
const [coursesLoading, setCoursesLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlan(planId);
|
fetchPlan(planId);
|
||||||
@@ -88,30 +526,31 @@ export default function ViewPlan() {
|
|||||||
}, [planId]);
|
}, [planId]);
|
||||||
|
|
||||||
return (
|
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} />
|
<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">
|
{/* ── Sticky header ── */}
|
||||||
<AppBreadcrumb items={[
|
<div
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
|
||||||
{ label: "Plans", to: "/admin/tiers/plans" },
|
style={{ top: "var(--navbar-h)" }}
|
||||||
{ label: plan?.label ?? `Plan #${planId}` },
|
>
|
||||||
]} />
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
</div>
|
<div className="flex items-center gap-3 py-3">
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
<div className="flex items-start justify-between gap-3 mb-6">
|
<ArrowLeft className="h-4 w-4" />
|
||||||
<div className="flex items-center gap-3">
|
</Button>
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
<div className="flex-1 min-w-0">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||||
</Button>
|
<CreditCard className="h-5 w-5 text-muted-foreground" />
|
||||||
<div>
|
View Plan
|
||||||
<h1 className="text-xl font-semibold">Plan Details</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">View plan information.</p>
|
{plan && (
|
||||||
</div>
|
<p className="text-sm text-muted-foreground capitalize">
|
||||||
|
{plan.tier} — {plan.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
{activeTab === "details" && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -119,107 +558,55 @@ export default function ViewPlan() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
Edit
|
Edit Plan
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && !plan ? (
|
{/* Underline tabs */}
|
||||||
<LoadingSkeleton />
|
<div className="flex gap-1 pb-0 -mb-px">
|
||||||
) : !plan ? (
|
{TABS.map(({ key, label, icon: Icon }) => (
|
||||||
<p className="text-sm text-muted-foreground">Plan not found.</p>
|
<button
|
||||||
) : (
|
key={key}
|
||||||
<div className="space-y-5">
|
onClick={() => setActiveTab(key)}
|
||||||
|
className={[
|
||||||
<SectionCard icon={Tag} title="Plan Details">
|
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
|
||||||
<div className="grid grid-cols-2 gap-4">
|
activeTab === key
|
||||||
<InfoRow label="Label">{plan.label}</InfoRow>
|
? "border-primary text-primary"
|
||||||
<InfoRow label="Tier">
|
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||||
{(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
|
].join(" ")}
|
||||||
</InfoRow>
|
>
|
||||||
<InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
|
<Icon className="h-3.5 w-3.5" />
|
||||||
<InfoRow label="Price">
|
{label}
|
||||||
{plan.currency} {Number(plan.price).toFixed(2)}
|
</button>
|
||||||
</InfoRow>
|
))}
|
||||||
<InfoRow label="Currency">{plan.currency}</InfoRow>
|
</div>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 TierCategories from '../pages/tiers/TierCategories';
|
||||||
import ArchivedPlanList from '../pages/tiers/ArchivedPlanList';
|
import ArchivedPlanList from '../pages/tiers/ArchivedPlanList';
|
||||||
import PaymentPolicy from '../pages/tiers/PaymentPolicy';
|
import PaymentPolicy from '../pages/tiers/PaymentPolicy';
|
||||||
import LocalizedPrices from '../pages/tiers/LocalizedPrices';
|
|
||||||
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
|
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
|
||||||
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
|
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
|
||||||
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
|
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 EditAdvertisement from '../pages/advertisements/EditAdvertisement'
|
||||||
import ViewAdvertisement from '../pages/advertisements/ViewAdvertisement'
|
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
|
// Activity
|
||||||
import ActivityFeed from '../pages/activity/ActivityFeed'
|
import ActivityFeed from '../pages/activity/ActivityFeed'
|
||||||
import UserActivityPage from '../pages/activity/UserActivityPage'
|
import UserActivityPage from '../pages/activity/UserActivityPage'
|
||||||
@@ -259,10 +275,8 @@ export const AdminRoutes = {
|
|||||||
{ path: ':planId/view', element: <ViewPlan /> },
|
{ path: ':planId/view', element: <ViewPlan /> },
|
||||||
{ path: ':planId/edit', element: <EditPlan /> },
|
{ path: ':planId/edit', element: <EditPlan /> },
|
||||||
{ path: ':planId/payment-policy', element: <PaymentPolicy /> },
|
{ path: ':planId/payment-policy', element: <PaymentPolicy /> },
|
||||||
{ path: ':planId/prices', element: <LocalizedPrices /> },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ path: 'prices', element: <LocalizedPrices /> },
|
|
||||||
{ path: 'system-badges', element: <SystemBadges /> },
|
{ path: 'system-badges', element: <SystemBadges /> },
|
||||||
{
|
{
|
||||||
path: 'categories',
|
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
|
// Activity Feed
|
||||||
{ path: 'activity', element: <ActivityFeed /> },
|
{ 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:
|
* Props:
|
||||||
* course — { title, ... } the completed course
|
* course — { title, pending_certificate, certificate, ... } the completed course
|
||||||
*/
|
*/
|
||||||
const CourseCompleteBlock = ({ 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 (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<div className="rounded-xl border bg-card p-6 space-y-6 text-center sm:p-10">
|
<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.
|
You've passed all required units and the final assessment for this course.
|
||||||
</p>
|
</p>
|
||||||
</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" />
|
{isIssued ? (
|
||||||
<div>
|
<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">
|
||||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Certificate Pending</p>
|
<CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />
|
||||||
<p className="text-xs text-muted-foreground">
|
<div>
|
||||||
Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications.
|
<p className="text-sm font-medium text-green-700 dark:text-green-400">Certificate Issued</p>
|
||||||
</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>
|
) : (
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CourseCompleteBlock;
|
export default CourseCompleteBlock;
|
||||||
|
|||||||
@@ -179,18 +179,13 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
|
|||||||
<div>
|
<div>
|
||||||
<h1
|
<h1
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!taskId || !groupId || !taskListId) return;
|
if (!info?.course_id) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(
|
navigate(`/course/${info.course_id}/unit`, {
|
||||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
|
||||||
{ state: { course: { id: course.id, reference_id: course.reference_id, title: course.title } } }
|
});
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
|
className="text-base font-semibold leading-snug line-clamp-2 transition-colors text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
|
||||||
taskId
|
|
||||||
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
|
|
||||||
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{course.title}
|
{course.title}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -242,18 +237,12 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!info?.course_id) return;
|
if (!info?.course_id) return;
|
||||||
if (taskId && groupId && taskListId) {
|
navigate(`/course/${info.course_id}/unit`, {
|
||||||
// Task context — read inside ViewRequirement
|
state: {
|
||||||
navigate(
|
...(allRead ? { seekFirstIncomplete: true } : {}),
|
||||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
|
||||||
{ 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 } : {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={done || !info?.course_id}
|
disabled={done || !info?.course_id}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -147,10 +147,16 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={lesson.id}
|
key={lesson.id}
|
||||||
onClick={() => !isFetching && navigate(
|
onClick={() => {
|
||||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
if (isFetching || !info?.unit?.course?.course_id) return;
|
||||||
{ state: { lesson } },
|
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 ${
|
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-96 shrink-0 ${
|
||||||
isFetching
|
isFetching
|
||||||
? 'opacity-60 cursor-wait'
|
? 'opacity-60 cursor-wait'
|
||||||
|
|||||||
@@ -194,18 +194,13 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
|||||||
<div>
|
<div>
|
||||||
<h1
|
<h1
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!taskId || !groupId || !taskListId) return;
|
if (!info?.course?.course_id) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(
|
navigate(`/course/${info.course.course_id}/unit`, {
|
||||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
|
||||||
{ state: { unit } }
|
});
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
|
className="text-base font-semibold leading-snug line-clamp-2 transition-colors text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
|
||||||
taskId
|
|
||||||
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
|
|
||||||
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{unit.title}
|
{unit.title}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -251,10 +246,13 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
|||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(
|
onClick={() => {
|
||||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
const info = details[selected?.reference_id];
|
||||||
{ state: { unit: selected } },
|
if (!info?.course?.course_id) return;
|
||||||
)}
|
navigate(`/course/${info.course.course_id}/unit`, {
|
||||||
|
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
|
||||||
|
});
|
||||||
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
getProgress(selected ?? {}) >= 100 ||
|
getProgress(selected ?? {}) >= 100 ||
|
||||||
locked[selected?.reference_id] ||
|
locked[selected?.reference_id] ||
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -20,12 +20,10 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useProfile } from "@/contexts/ProfileProvider";
|
import { useProfile } from "@/contexts/ProfileProvider";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
|
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -285,74 +283,122 @@ function NewsletterSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Currency Preference ─────────────────────────────────────────────────────
|
// ─── Advertisements ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CurrencySection() {
|
const AD_OPTIONS = [
|
||||||
const { profile, getProfile } = useProfile();
|
{
|
||||||
const { setCurrency } = useCurrencyPreference();
|
key: "show_popup_ads",
|
||||||
const [currencies, setCurrencies] = useState([]);
|
label: "Popup ads",
|
||||||
const [localValue, setLocalValue] = useState("USD");
|
description: "Show promotional popups when you open pages like the dashboard.",
|
||||||
const [saving, setSaving] = useState(false);
|
},
|
||||||
|
{
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
getProfile();
|
getProfile();
|
||||||
api.get("/client/tiers/currencies")
|
|
||||||
.then(({ data }) => setCurrencies(data.data ?? []))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync local value when profile loads or changes
|
const showPopupAds = profile?.personal_info?.show_popup_ads ?? true;
|
||||||
useEffect(() => {
|
const showOtherAds = profile?.personal_info?.show_other_ads ?? true;
|
||||||
if (profile?.preferred_currency) setLocalValue(profile.preferred_currency);
|
// No separate stored flag — "hidden" just means both underlying toggles are off,
|
||||||
}, [profile?.preferred_currency]);
|
// so it can never drift out of sync with them.
|
||||||
|
const hideAllAds = !showPopupAds && !showOtherAds;
|
||||||
|
|
||||||
const savedValue = profile?.preferred_currency ?? "USD";
|
const handleToggle = async (key, value) => {
|
||||||
const isDirty = localValue !== savedValue;
|
// 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 () => {
|
const confirmTurnOffPopupAndOther = async () => {
|
||||||
setSaving(true);
|
setConfirmPopupOff(false);
|
||||||
try {
|
const result = await updateProfile({ show_popup_ads: false, show_other_ads: false });
|
||||||
await api.patch("/client/profile/currency", { currency: localValue });
|
if (result?.success) {
|
||||||
setCurrency(localValue);
|
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
|
||||||
toast.success("Currency preference saved.");
|
}
|
||||||
} catch (err) {
|
};
|
||||||
toast.error(err?.response?.data?.message ?? "Could not save preference.");
|
|
||||||
setLocalValue(savedValue); // revert on error
|
const handleHideAllToggle = async (hide) => {
|
||||||
} finally {
|
const result = await updateProfile({ show_popup_ads: !hide, show_other_ads: !hide });
|
||||||
setSaving(false);
|
if (result?.success) {
|
||||||
|
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<>
|
||||||
<div className="space-y-1.5 max-w-xs">
|
<div className="space-y-4">
|
||||||
<Label>Preferred currency</Label>
|
<div>
|
||||||
{!profile ? (
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Skeleton className="h-9 w-full" />
|
<div className="space-y-0.5">
|
||||||
) : (
|
<p className="text-sm font-medium">Hide all ads</p>
|
||||||
<Select value={localValue} onValueChange={setLocalValue} disabled={saving}>
|
<p className="text-xs text-muted-foreground">
|
||||||
<SelectTrigger>
|
Turn off every advertisement across the platform, popups included.
|
||||||
<SelectValue />
|
</p>
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
{profileLoading ? (
|
||||||
{currencies.map((c) => (
|
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
|
||||||
<SelectItem key={c.code} value={c.code}>
|
) : (
|
||||||
{c.code} — {c.name}
|
<Switch checked={hideAllAds} onCheckedChange={handleHideAllToggle} />
|
||||||
</SelectItem>
|
)}
|
||||||
))}
|
</div>
|
||||||
</SelectContent>
|
<Separator className="mt-4" />
|
||||||
</Select>
|
</div>
|
||||||
)}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
{AD_OPTIONS.map((opt, i) => (
|
||||||
Plans without localized prices always display in USD regardless of this setting.
|
<div key={opt.key}>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
{isDirty && (
|
|
||||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
<AlertDialog open={confirmPopupOff} onOpenChange={setConfirmPopupOff}>
|
||||||
{saving ? "Saving…" : "Save"}
|
<AlertDialogContent>
|
||||||
</Button>
|
<AlertDialogHeader>
|
||||||
)}
|
<AlertDialogTitle>Turn off popup ads?</AlertDialogTitle>
|
||||||
</div>
|
<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 />
|
<NewsletterSection />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section icon={Globe} title="Currency Preference" description="Set the currency used to display plan prices across the platform.">
|
<Section icon={Megaphone} title="Advertisements" description="Control which advertisements you see across the platform.">
|
||||||
<CurrencySection />
|
<AdvertisementsSection />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
|
<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