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