Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 12:39:47 +08:00
parent 71f758fe0b
commit fa92d924f4
50 changed files with 2202 additions and 2623 deletions
@@ -11,10 +11,6 @@ export const columnPinning = {
const cellOverrides = {};
// TODO(ads-9): Fix Sort and Columns on the Archived Advertisements table —
// sorting/column visibility currently misbehaves. Compare against a working
// DataTable usage elsewhere in admin/config to see what's diverging (likely
// an attributes/sort-key mismatch coming out of the paginate() response).
/**
* Builds the full column array for the Archived Advertisements table.
*
@@ -1,172 +0,0 @@
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>
);
}
@@ -1,255 +0,0 @@
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} />; }
@@ -7,7 +7,7 @@ import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
House, Plus, Trash2, ImagePlus, MapPin, FileText,
Link2, CalendarClock, Check, ChevronLeft, ChevronRight,
LayoutTemplate, CalendarClock, Check, ChevronLeft, ChevronRight,
} from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
@@ -25,16 +25,18 @@ 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 { DateTimePicker } from "@/components/ui/date-time-picker";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { PlacementSkeleton } from "@/components/generic/PlacementSkeleton";
import { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
import { MAX_CTAS, CONTENT_MODES } from "@/data/advertisement.data";
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
placement: z.string().min(1, "Placement is required."),
content_mode: z.enum(["image", "content"]).default("image"),
badge_label: z.string().optional(),
headline: z.string().optional(),
description: z.string().optional(),
@@ -45,6 +47,16 @@ const schema = z.object({
link: z.string().min(1, "Link is required."),
variant: z.enum(["default", "outline"]).default("default"),
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
redirect_link: z.string().optional(),
landing_page: z.object({
title: z.string().optional(),
description: z.string().optional(),
body: z.string().optional(),
links: z.array(z.object({
label: z.string().optional(),
link: z.string().optional(),
})).default([]),
}).default({}),
start_date: z.string().optional(),
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
@@ -64,31 +76,16 @@ const schema = z.object({
}
});
// TODO(ads-6): Rework this wizard to match the target spec:
// Step 1 Placement — choose ONLY Dashboard, Tier Plans, or Course Details
// (depends on ads-1 registry re-categorization).
// Step 2 Content — pick "full image" vs "content + image":
// full image -> image only
// content+img -> badge label, headline, description,
// CTAs, redirect link
// Step 3 Page Builder — only shown when no redirect link was provided;
// builds an internal landing page (title, description,
// body, links, etc.) — new step, doesn't exist yet.
// Step 4 Scheduling & Display — start date, end date, order, and an
// active/draft switch labeled "Draft" when off
// (currently has start/end/order but check the
// on/off switch's Draft/Inactive labeling matches).
// Step 5 Review — display all details.
// ─── Steps ────────────────────────────────────────────────────────────────────
// richOnly steps are skipped entirely for placements whose format isn't a
// RICH_CONTENT_TYPES format (banner/popup/sidebar never had CTAs).
// The Page Builder step only shows up when no redirect_link was given — it's
// the alternative click-through destination (an internally-authored landing
// page) for ads that don't link straight out to a URL.
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." },
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Tier Plans, or Course Details." },
{ id: "content", label: "Content", icon: FileText, description: "Full image, or content with badge, headline, description, and CTAs." },
{ id: "pageBuilder", label: "Page Builder", icon: LayoutTemplate, description: "No redirect link? Build an internal landing page for this ad instead.", skippable: true },
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates, display order, and draft/active status." },
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this advertisement." },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -152,49 +149,27 @@ function Stepper({ steps, stepIndex }) {
);
}
// ─── Step: Placement ────────────────────────────────────────────────────────
// ─── Step 1: Placement ──────────────────────────────────────────────────────
function StepPlacement({ selectedPage, setSelectedPage, placement, setValue, positionOptions, errors, format, isBanner, watch }) {
function StepPlacement({ placement, setValue, 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>
<Label className="mb-1.5 block">Placement</Label>
<Select
value={placement || undefined}
onValueChange={(v) => setValue("placement", v, { shouldValidate: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select a placement" />
</SelectTrigger>
<SelectContent>
{PLACEMENTS.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.pageLabel}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.placement?.message} />
</div>
{placement && (
@@ -231,47 +206,9 @@ function StepPlacement({ selectedPage, setSelectedPage, placement, setValue, pos
);
}
// ─── Step: Content ──────────────────────────────────────────────────────────
// ─── Step 2: 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 }) {
function StepImagePicker({ selectedAsset, imageUrl, setPickerOpen }) {
return selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
@@ -298,71 +235,191 @@ function StepImage({ selectedAsset, imageUrl, setPickerOpen }) {
);
}
// ─── Step: CTAs ─────────────────────────────────────────────────────────────
function StepContent({
register, errors, setValue, watch, description, format,
selectedAsset, imageUrl, setPickerOpen,
ctaFields, appendCta, removeCta,
}) {
const contentMode = watch("content_mode");
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)}
<div className="space-y-5">
<div>
<Label className="mb-1.5 block">Content type</Label>
<div className="grid grid-cols-2 gap-3">
{CONTENT_MODES.map((m) => (
<button
key={m.value}
type="button"
onClick={() => setValue("content_mode", m.value, { shouldValidate: true })}
className={cn(
"rounded-lg border p-3 text-left transition-colors",
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
)}
>
<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>
<p className="text-sm font-medium">{m.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{m.value === "image" ? "Just an image, no text overlay." : "Badge, headline, description, and CTAs."}
</p>
</button>
))}
</div>
</div>
<div>
<Label className="mb-1.5 block">Image</Label>
<StepImagePicker selectedAsset={selectedAsset} imageUrl={imageUrl} setPickerOpen={setPickerOpen} />
</div>
{contentMode === "content" && (
<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>
<Label className="mb-1.5 block">Calls to action</Label>
<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>
</div>
</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>
)}
<Separator />
<div>
<Label className="mb-1.5 block">Redirect link</Label>
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
<p className="text-xs text-muted-foreground mt-1.5">
Where clicking the ad itself goes to. Leave blank to build an internal landing page in the next step instead.
</p>
</div>
</div>
);
}
// ─── Step: Scheduling & Display ─────────────────────────────────────────────
// ─── Step 3: Page Builder ───────────────────────────────────────────────────
function StepPageBuilder({ register, linkFields, appendLink, removeLink }) {
return (
<div className="space-y-5">
<div>
<Label className="mb-1.5 block">Page title</Label>
<Input placeholder="e.g. Why upgrade to Pro" {...register("landing_page.title")} />
</div>
<div>
<Label className="mb-1.5 block">Page description</Label>
<Textarea rows={2} placeholder="Short summary shown under the title" {...register("landing_page.description")} />
</div>
<div>
<Label className="mb-1.5 block">Body</Label>
<Textarea rows={6} placeholder="Main page content" {...register("landing_page.body")} />
</div>
<div>
<Label className="mb-1.5 block">Links</Label>
<div className="space-y-2">
{linkFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
<Input placeholder="Label" className="flex-1" {...register(`landing_page.links.${index}.label`)} />
<Input placeholder="URL or path" className="flex-1" {...register(`landing_page.links.${index}.link`)} />
<Button type="button" variant="ghost" size="icon" onClick={() => removeLink(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={() => appendLink({ label: "", link: "" })}>
<Plus className="size-3.5" />
Add link
</Button>
</div>
</div>
</div>
);
}
// ─── Step 4: Scheduling & Display ───────────────────────────────────────────
function StepScheduling({ register, watch, setValue }) {
const isActive = watch("is_active");
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")} />
<DateTimePicker
value={watch("start_date") || null}
onChange={(iso) => setValue("start_date", iso ?? "", { shouldValidate: true })}
placeholder="No start date"
/>
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<Input type="datetime-local" {...register("end_date")} />
<DateTimePicker
value={watch("end_date") || null}
onChange={(iso) => setValue("end_date", iso ?? "", { shouldValidate: true })}
placeholder="No end date"
/>
</div>
</div>
<Separator />
@@ -372,9 +429,9 @@ function StepScheduling({ register, watch, setValue }) {
<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>
<Label className="text-sm">{isActive ? "Active" : "Draft"}</Label>
<Switch
checked={watch("is_active")}
checked={isActive}
onCheckedChange={(v) => setValue("is_active", v)}
/>
</div>
@@ -383,7 +440,7 @@ function StepScheduling({ register, watch, setValue }) {
);
}
// ─── Step: Review ───────────────────────────────────────────────────────────
// ─── Step 5: Review ─────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
@@ -398,6 +455,7 @@ function SummaryRow({ label, value }) {
function StepReview({ data, selectedAsset, imageUrl }) {
const placementMeta = PLACEMENT_MAP[data.placement];
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
const hasLandingPage = !data.redirect_link && (data.landing_page?.title || data.landing_page?.body);
return (
<div className="space-y-4">
@@ -407,16 +465,20 @@ function StepReview({ data, selectedAsset, imageUrl }) {
<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} />
<SummaryRow label="Page" value={placementMeta?.pageLabel} />
<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} />
<SummaryRow label="Type" value={data.content_mode === "content" ? "Content + image" : "Full image"} />
{data.content_mode === "content" && (
<>
<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">
@@ -441,12 +503,26 @@ function StepReview({ data, selectedAsset, imageUrl }) {
</div>
)}
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Click-through</p>
{data.redirect_link ? (
<SummaryRow label="Redirect link" value={data.redirect_link} />
) : hasLandingPage ? (
<>
<SummaryRow label="Page title" value={data.landing_page?.title} />
<SummaryRow label="Links" value={(data.landing_page?.links ?? []).filter((l) => l.label || l.link).length || null} />
</>
) : (
<p className="text-sm text-muted-foreground">No redirect link or landing page set — this ad won't link anywhere when clicked.</p>
)}
</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"} />
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
</div>
</div>
);
@@ -462,7 +538,6 @@ export default function AddAdvertisement() {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
const [selectedPage, setSelectedPage] = useState(null);
const [step, setStep] = useState(0);
const {
@@ -478,11 +553,14 @@ export default function AddAdvertisement() {
resolver: zodResolver(schema),
defaultValues: {
placement: undefined,
content_mode: "image",
badge_label: "",
headline: "",
description: "",
image_asset_id: null,
ctas: [],
redirect_link: "",
landing_page: { title: "", description: "", body: "", links: [] },
start_date: "",
end_date: "",
order: 0,
@@ -492,23 +570,23 @@ export default function AddAdvertisement() {
});
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const { fields: linkFields, append: appendLink, remove: removeLink } = useFieldArray({ control, name: "landing_page.links" });
// selectedPage/selectedAsset live outside the form and their setValue()
// calls don't pass shouldDirty, so isDirty alone would miss them.
// selectedAsset lives outside the form and its setValue() call doesn't pass
// shouldDirty, so isDirty alone would miss it.
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
isDirty || !!selectedAsset || !!selectedPage
isDirty || !!selectedAsset
);
const placement = watch("placement");
const description = watch("description");
const redirectLink = watch("redirect_link");
const format = PLACEMENT_MAP[placement]?.format;
const showRichContent = RICH_CONTENT_TYPES.includes(format);
const isBanner = format === "banner";
const positionOptions = AD_PAGES.find((p) => p.page === selectedPage)?.placements ?? [];
const steps = useMemo(
() => ALL_STEPS.filter((s) => !s.richOnly || showRichContent),
[showRichContent]
() => ALL_STEPS.filter((s) => !s.skippable || !redirectLink?.trim()),
[redirectLink]
);
const stepIndex = Math.min(step, steps.length - 1);
const current = steps[stepIndex];
@@ -523,8 +601,7 @@ export default function AddAdvertisement() {
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"];
else if (current.id === "content") fields = watch("content_mode") === "content" ? ["headline", "description", "badge_label", "ctas"] : [];
const valid = fields.length ? await trigger(fields) : true;
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
@@ -537,6 +614,8 @@ export default function AddAdvertisement() {
const payload = {
...values,
image_asset_id: values.image_asset_id || null,
redirect_link: values.redirect_link || null,
landing_page: values.redirect_link ? null : values.landing_page,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
@@ -557,7 +636,7 @@ export default function AddAdvertisement() {
<div className="w-full max-w-2xl pb-10 space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
<p className="text-sm text-muted-foreground">Create a banner, popup, or hero placement.</p>
<p className="text-sm text-muted-foreground">Create a hero or banner placement.</p>
</div>
<Stepper steps={steps} stepIndex={stepIndex} />
@@ -570,11 +649,8 @@ export default function AddAdvertisement() {
{current.id === "placement" && (
<StepPlacement
selectedPage={selectedPage}
setSelectedPage={setSelectedPage}
placement={placement}
setValue={setValue}
positionOptions={positionOptions}
errors={errors}
format={format}
isBanner={isBanner}
@@ -585,25 +661,26 @@ export default function AddAdvertisement() {
<StepContent
register={register}
errors={errors}
showRichContent={showRichContent}
setValue={setValue}
watch={watch}
description={description}
format={format}
/>
)}
{current.id === "image" && (
<StepImage selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} setPickerOpen={setPickerOpen} />
)}
{current.id === "ctas" && (
<StepCtas
selectedAsset={selectedAsset}
imageUrl={imagePreviewUrl}
setPickerOpen={setPickerOpen}
ctaFields={ctaFields}
register={register}
errors={errors}
watch={watch}
setValue={setValue}
appendCta={appendCta}
removeCta={removeCta}
/>
)}
{current.id === "pageBuilder" && (
<StepPageBuilder
register={register}
linkFields={linkFields}
appendLink={appendLink}
removeLink={removeLink}
/>
)}
{current.id === "scheduling" && (
<StepScheduling register={register} watch={watch} setValue={setValue} />
)}
@@ -18,37 +18,40 @@ import {
} from "@/components/ui/alert-dialog";
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
import { PLACEMENT_MAP } from "@/data/placement.data";
import { TablePagination } from "@/components/generic/Table/TablePagination";
const PAGE_SIZE = 24;
export default function AdvertisementList() {
const navigate = useNavigate();
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
const [typeFilter, setTypeFilter] = useState("all");
const [placementFilter, setPlacementFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
// TODO(ads-3): Filters are not actually filtering — the "status" filter in
// particular compares against the stored `status` column, but status is only
// recomputed on read (see deriveStatus() in
// controllers/admin/advertisements.controller.js) and never persisted back
// to the DB. An ad that lapsed to "expired" still has status="active" in
// the row, so filtering by status here misses/matches the wrong rows.
// Needs either persisting the derived status on write/read, or filtering
// server-side using the same derivation logic. Also verify type/placement
// filters actually round-trip once ads-1/ads-2 land.
useEffect(() => {
const buildFilters = () => {
const filters = [];
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
if (placementFilter !== "all") filters.push({ field: "placement", value: placementFilter });
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
if (typeFilter !== "all") filters.push({ id: "type", value: typeFilter });
if (statusFilter !== "all") filters.push({ id: "status", value: statusFilter });
if (search.trim()) filters.push({ id: "headline", value: search.trim() });
return filters;
};
fetchAdvertisements({ page: 1, limit: 24, filters });
// Single source of truth for fetching — filter setters below always pair
// their state update with setPage(1) in the same handler so this only
// ever fires once per change (no separate "reset page" effect racing it).
useEffect(() => {
fetchAdvertisements({ page, limit: PAGE_SIZE, filters: buildFilters() });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeFilter, placementFilter, statusFilter, search]);
}, [typeFilter, statusFilter, search, page]);
const handleTypeFilter = (v) => { setTypeFilter(v); setPage(1); };
const handleStatusFilter = (v) => { setStatusFilter(v); setPage(1); };
const runSearch = () => { setSearch(searchInput); setPage(1); };
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -77,7 +80,7 @@ export default function AdvertisementList() {
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
<p className="text-sm text-muted-foreground">Manage public-facing banners, popups, and promotional placements</p>
<p className="text-sm text-muted-foreground">Manage public-facing hero and banner placements</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => navigate("/admin/advertisements/archived")}>
@@ -101,7 +104,7 @@ export default function AdvertisementList() {
{/* ── Filters ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<Select value={typeFilter} onValueChange={handleTypeFilter}>
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All types" />
</SelectTrigger>
@@ -113,20 +116,7 @@ export default function AdvertisementList() {
</SelectContent>
</Select>
{/* TODO(ads-2): Remove this "All placements" dropdown entirely. */}
<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={handleStatusFilter}>
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
@@ -138,31 +128,23 @@ export default function AdvertisementList() {
</SelectContent>
</Select>
{/* TODO(ads-5): Verify this already satisfies the spec — search only
fires on button click / Enter (`search` state, not `searchInput`,
drives the fetch effect above), typing alone does not refetch.
Looks done already; double-check then mark complete. */}
<div className="flex items-center gap-2 flex-1 min-w-[160px]">
<div className="relative flex-1">
<div className="relative w-64">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search advertisements..."
className="pl-8 bg-background"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
/>
</div>
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={() => setSearch(searchInput)} aria-label="Search">
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={runSearch} aria-label="Search">
<Search className="size-4" />
</Button>
</div>
</div>
{/* TODO(ads-8): Add pagination controls for this grid — currently
always fetches page 1 / limit 24 with no way to reach further
pages (see `pagination` from useAdvertisements, already returned
by the API but unused here). */}
{/* ── Grid ───────────────────────────────────────────────────── */}
{loading ? (
<div className="flex items-center justify-center py-20">
@@ -171,17 +153,28 @@ export default function AdvertisementList() {
) : advertisements.length === 0 ? (
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{advertisements.map((ad) => (
<AdvertisementCard
key={ad.advertisement_id}
ad={ad}
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
onArchive={() => handleArchive(ad.advertisement_id)}
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{advertisements.map((ad) => (
<AdvertisementCard
key={ad.advertisement_id}
ad={ad}
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
onArchive={() => handleArchive(ad.advertisement_id)}
/>
))}
</div>
<div className="bg-background rounded-lg border">
<TablePagination
pagination={pagination}
onPageChange={setPage}
rowCount={advertisements.length}
recordLabel="advertisement"
/>
))}
</div>
</div>
</>
)}
</div>
</div>
@@ -298,7 +291,7 @@ function EmptyState({ onCreate }) {
<Megaphone className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">No advertisements yet</p>
<p className="text-sm text-muted-foreground">Create your first banner, popup, or hero placement.</p>
<p className="text-sm text-muted-foreground">Create your first hero or banner placement.</p>
</div>
<Button onClick={onCreate}>
<Plus className="size-4" />
@@ -11,6 +11,7 @@ import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { resolveAssetSrc } from "@/utils/media.util";
import { cn } from "@/lib/utils";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -18,16 +19,19 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
import { MAX_CTAS, CONTENT_MODES } from "@/data/advertisement.data";
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
placement: z.string().min(1, "Placement is required."),
content_mode: z.enum(["image", "content"]).default("image"),
badge_label: z.string().optional(),
headline: z.string().optional(),
description: z.string().optional(),
@@ -38,6 +42,16 @@ const schema = z.object({
link: z.string().min(1, "Link is required."),
variant: z.enum(["default", "outline"]).default("default"),
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
redirect_link: z.string().optional(),
landing_page: z.object({
title: z.string().optional(),
description: z.string().optional(),
body: z.string().optional(),
links: z.array(z.object({
label: z.string().optional(),
link: z.string().optional(),
})).default([]),
}).default({}),
start_date: z.string().optional(),
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
@@ -78,14 +92,6 @@ function SectionCard({ title, description, children }) {
);
}
// Convert ISO datetime to value usable by <input type="datetime-local">
function toLocalInputValue(iso) {
if (!iso) return "";
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
@@ -109,13 +115,9 @@ export default function EditAdvertisement() {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
const [selectedPage, setSelectedPage] = useState(null);
// Gates the form's first paint until the fetched advertisement has been
// applied via reset() + setSelectedPage(). Without this, the Page/Position
// selects briefly mount with their empty defaultValues (no page selected,
// no position options yet) before the fetch resolves — that first paint is
// enough for the position <Select> to lose track of the eventual value,
// leaving it visually unselected even after reset() runs.
// applied via reset(). Without this, fields briefly mount with their empty
// defaultValues before the fetch resolves.
const [ready, setReady] = useState(false);
const {
@@ -130,11 +132,14 @@ export default function EditAdvertisement() {
resolver: zodResolver(schema),
defaultValues: {
placement: undefined,
content_mode: "image",
badge_label: "",
headline: "",
description: "",
image_asset_id: null,
ctas: [],
redirect_link: "",
landing_page: { title: "", description: "", body: "", links: [] },
start_date: "",
end_date: "",
order: 0,
@@ -144,15 +149,16 @@ export default function EditAdvertisement() {
});
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const { fields: linkFields, append: appendLink, remove: removeLink } = useFieldArray({ control, name: "landing_page.links" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const placement = watch("placement");
const description = watch("description");
const contentMode = watch("content_mode");
const redirectLink = watch("redirect_link");
const format = PLACEMENT_MAP[placement]?.format;
const showRichContent = RICH_CONTENT_TYPES.includes(format);
const isBanner = format === "banner";
const positionOptions = AD_PAGES.find((p) => p.page === selectedPage)?.placements ?? [];
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -174,10 +180,10 @@ export default function EditAdvertisement() {
// — no separate token round-trip needed.
setImagePreviewUrl(resolveAssetSrc(ad.image));
}
setSelectedPage(PLACEMENT_MAP[ad.placement]?.page ?? null);
reset({
placement: ad.placement ?? undefined,
content_mode: ad.content_mode ?? "image",
badge_label: ad.badge_label ?? "",
headline: ad.headline ?? "",
description: ad.description ?? "",
@@ -187,8 +193,15 @@ export default function EditAdvertisement() {
link: c.link ?? "",
variant: c.variant ?? (i === 0 ? "default" : "outline"),
})),
start_date: toLocalInputValue(ad.start_date),
end_date: toLocalInputValue(ad.end_date),
redirect_link: ad.redirect_link ?? "",
landing_page: {
title: ad.landing_page?.title ?? "",
description: ad.landing_page?.description ?? "",
body: ad.landing_page?.body ?? "",
links: (ad.landing_page?.links ?? []).map((l) => ({ label: l.label ?? "", link: l.link ?? "" })),
},
start_date: ad.start_date ?? "",
end_date: ad.end_date ?? "",
order: ad.order ?? 0,
is_active: ad.is_active ?? true,
size: ad.size ?? null,
@@ -205,6 +218,8 @@ export default function EditAdvertisement() {
const payload = {
...values,
image_asset_id: values.image_asset_id || null,
redirect_link: values.redirect_link || null,
landing_page: values.redirect_link ? null : values.landing_page,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
@@ -234,49 +249,27 @@ export default function EditAdvertisement() {
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit advertisement</h1>
<p className="text-sm text-muted-foreground mb-6">Update this banner, popup, or hero placement.</p>
<p className="text-sm text-muted-foreground mb-6">Update this hero or banner placement.</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Placement" description="Where this advertisement will appear.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Page</Label>
<Select
value={selectedPage ?? undefined}
onValueChange={(v) => {
setSelectedPage(v);
setValue("placement", "", { shouldValidate: false, shouldDirty: true });
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a page" />
</SelectTrigger>
<SelectContent>
{AD_PAGES.map((p) => (
<SelectItem key={p.page} value={p.page}>{p.pageLabel}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block">Position</Label>
<Select
value={placement || undefined}
onValueChange={(v) => setValue("placement", v, { shouldValidate: true, shouldDirty: true })}
disabled={!selectedPage}
>
<SelectTrigger>
<SelectValue placeholder={selectedPage ? "Select a position" : "Select a page first"} />
</SelectTrigger>
<SelectContent>
{positionOptions.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.slotLabel}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.placement?.message} />
</div>
<div>
<Label className="mb-1.5 block">Placement</Label>
<Select
value={placement || undefined}
onValueChange={(v) => setValue("placement", v, { shouldValidate: true, shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select a placement" />
</SelectTrigger>
<SelectContent>
{PLACEMENTS.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.pageLabel}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.placement?.message} />
</div>
{format && (
@@ -305,39 +298,51 @@ export default function EditAdvertisement() {
)}
</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")} />
{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>
</SectionCard>
)}
<SectionCard title="Content" description="Full image, or content with badge, headline, description, and CTAs.">
<div className="grid grid-cols-2 gap-3">
{CONTENT_MODES.map((m) => (
<button
key={m.value}
type="button"
onClick={() => setValue("content_mode", m.value, { shouldValidate: true, shouldDirty: true })}
className={cn(
"rounded-lg border p-3 text-left transition-colors",
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
)}
>
<p className="text-sm font-medium">{m.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{m.value === "image" ? "Just an image, no text overlay." : "Badge, headline, description, and CTAs."}
</p>
</button>
))}
</div>
{!showRichContent && (
<SectionCard title="Content" description="Optional headline for this placement.">
<div>
<Label className="mb-1.5 block">Headline (optional)</Label>
<Input placeholder="Internal label for this ad" {...register("headline")} />
</div>
</SectionCard>
)}
{contentMode === "content" && (
<>
<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>
</>
)}
</SectionCard>
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
{selectedAsset ? (
@@ -366,7 +371,7 @@ export default function EditAdvertisement() {
)}
</SectionCard>
{showRichContent && (
{contentMode === "content" && (
<SectionCard
title="Calls to action"
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
@@ -417,27 +422,81 @@ export default function EditAdvertisement() {
</SectionCard>
)}
<SectionCard title="Click-through" description="Where clicking the ad itself goes to.">
<div>
<Label className="mb-1.5 block">Redirect link</Label>
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
<p className="text-xs text-muted-foreground mt-1.5">
Leave blank to use the internal landing page below instead.
</p>
</div>
{!redirectLink?.trim() && (
<>
<Separator />
<div>
<Label className="mb-1.5 block">Page title</Label>
<Input placeholder="e.g. Why upgrade to Pro" {...register("landing_page.title")} />
</div>
<div>
<Label className="mb-1.5 block">Page description</Label>
<Textarea rows={2} placeholder="Short summary shown under the title" {...register("landing_page.description")} />
</div>
<div>
<Label className="mb-1.5 block">Body</Label>
<Textarea rows={6} placeholder="Main page content" {...register("landing_page.body")} />
</div>
<div>
<Label className="mb-1.5 block">Links</Label>
<div className="space-y-2">
{linkFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
<Input placeholder="Label" className="flex-1" {...register(`landing_page.links.${index}.label`)} />
<Input placeholder="URL or path" className="flex-1" {...register(`landing_page.links.${index}.link`)} />
<Button type="button" variant="ghost" size="icon" onClick={() => removeLink(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={() => appendLink({ label: "", link: "" })}>
<Plus className="size-3.5" />
Add link
</Button>
</div>
</div>
</>
)}
</SectionCard>
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Start date</Label>
<Input type="datetime-local" {...register("start_date")} />
<DateTimePicker
value={watch("start_date") || null}
onChange={(iso) => setValue("start_date", iso ?? "", { shouldDirty: true })}
placeholder="No start date"
/>
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<Input type="datetime-local" {...register("end_date")} />
<DateTimePicker
value={watch("end_date") || null}
onChange={(iso) => setValue("end_date", iso ?? "", { shouldDirty: true })}
placeholder="No end date"
/>
</div>
</div>
</SectionCard>
<SectionCard title="Display" description="Manual ordering and on/off switch.">
<SectionCard title="Display" description="Manual ordering and draft/active switch.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Order</Label>
<Input type="number" min={0} {...register("order")} />
</div>
<div className="flex items-center justify-between border rounded-md px-3 h-9">
<Label className="text-sm">Active</Label>
<Label className="text-sm">{watch("is_active") ? "Active" : "Draft"}</Label>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
@@ -473,4 +532,4 @@ export default function EditAdvertisement() {
{unsavedChangesDialog}
</section>
);
}
}
+7 -2
View File
@@ -8,6 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button";
@@ -55,7 +56,6 @@ function formatBytes(bytes) {
// ─── Thumbnail Drop Zone ──────────────────────────────────────────────────────
function ThumbnailDropZone({ currentUrl, newFile, onFile, onClear, error }) {
console.log(currentUrl)
const inputRef = useRef(null);
const preview = newFile
@@ -178,6 +178,11 @@ export default function EditAsset() {
const isVideo = asset?.file_type === "video";
const hasThumbnailChange = !!thumbnailRef.current;
// asset.file_url is redacted to null for S3-stored assets (see
// redactS3Url in assets.controller.js) — resolve the real preview src
// the same way ViewImageAsset.jsx does instead of reading it raw.
const { src: previewSrc } = useAssetPreviewSrc(asset, { scope: "admin" });
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || hasThumbnailChange);
const onSubmit = async (data) => {
@@ -265,7 +270,7 @@ export default function EditAsset() {
</Label>
<ThumbnailDropZone
key={thumbKey}
currentUrl={asset.file_type === "video" ? asset.thumbnail_url : asset.file_url}
currentUrl={asset.file_type === "video" ? asset.thumbnail_url : previewSrc}
newFile={thumbnailRef.current}
onFile={(f) => {
thumbnailRef.current = f;
@@ -1,13 +1,13 @@
// modules/admin/pages/notifications/AddNotificationBroadcast.jsx
import { useEffect, useState } from "react";
import { useState } from "react";
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 { format } from "date-fns";
import { House, Check, ArrowLeft, ArrowRight } from "lucide-react";
import api from "@/utils/api.util";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -18,10 +18,13 @@ 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 { DateTimePicker } from "@/components/ui/date-time-picker";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
// ─── Schema ─────────────────────────────────────────────────────────────────
@@ -34,6 +37,10 @@ const schema = z.object({
show_in_notifications: z.boolean().optional(),
link_mode: z.enum(["info", "link"]).optional(),
link_url: z.string().trim().optional(),
link_label: z.string().trim().optional(),
color: z.string().optional(),
start_date: z.string().optional(),
end_date: z.string().optional(),
}).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({
@@ -58,8 +65,25 @@ const schema = z.object({
path: ["link_url"],
});
}
if (data.start_date && data.end_date && new Date(data.start_date) > new Date(data.end_date)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End date must be after the start date.",
path: ["end_date"],
});
}
});
// ─── Steps config ───────────────────────────────────────────────────────────
const STEPS = [
{ label: "Content", description: "Title & message" },
{ label: "Target", description: "Who receives it" },
{ label: "Display", description: "Where it shows & schedule" },
{ label: "Review", description: "Confirm & save" },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
@@ -81,18 +105,72 @@ function SectionCard({ title, description, children }) {
);
}
function StepIndicator({ steps, current, onStepClick }) {
return (
<div className="flex items-start w-full mb-8">
{steps.flatMap((step, i) => {
const items = [
<button
key={`step-${i}`}
type="button"
onClick={() => onStepClick(i)}
className="flex flex-col items-center gap-1.5 shrink-0 group"
>
<div
className={[
"w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-semibold transition-all group-hover:opacity-80",
i < current
? "bg-primary border-primary text-primary-foreground"
: i === current
? "border-primary text-primary"
: "border-border text-muted-foreground",
].join(" ")}
>
{i < current ? <Check className="h-4 w-4" /> : i + 1}
</div>
<p
className={[
"text-[11px] font-medium text-center leading-tight whitespace-nowrap",
i === current ? "text-foreground" : "text-muted-foreground",
].join(" ")}
>
{step.label}
</p>
</button>,
];
if (i < steps.length - 1) {
items.push(
<div
key={`line-${i}`}
className={[
"flex-1 h-px mt-4 mx-2 shrink",
i < current ? "bg-primary" : "bg-border",
].join(" ")}
/>
);
}
return items;
})}
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function AddNotificationBroadcast() {
const navigate = useNavigate();
const { createBroadcast, loading } = useNotificationBroadcasts();
const { createBroadcast, sendBroadcast, loading } = useNotificationBroadcasts();
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(0);
const [targetLabel, setTargetLabel] = useState(null);
const {
register,
handleSubmit,
watch,
setValue,
trigger,
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
@@ -105,31 +183,24 @@ export default function AddNotificationBroadcast() {
show_in_notifications: true,
link_mode: "info",
link_url: "",
link_label: "",
color: "indigo",
start_date: "",
end_date: "",
},
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const [templates, setTemplates] = useState([]);
useEffect(() => {
api.get("/admin/announcement-templates")
.then(({ data }) => setTemplates((data.data ?? []).filter((t) => !t.is_system)))
.catch(() => {});
}, []);
const applyTemplate = (id) => {
const tpl = templates.find((t) => String(t.notification_template_id) === id);
if (!tpl) return;
setValue("title", tpl.title ?? "", { shouldValidate: true, shouldDirty: true });
setValue("message", tpl.message ?? "", { shouldValidate: true, shouldDirty: true });
};
const targetType = watch("target_type");
const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const showInSticky = watch("show_in_sticky");
const showInNotifications = watch("show_in_notifications");
const linkMode = watch("link_mode");
const color = watch("color");
const startDate = watch("start_date");
const endDate = watch("end_date");
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -137,7 +208,25 @@ export default function AddNotificationBroadcast() {
{ label: "New" },
];
const onSubmit = async (values) => {
// Guards the step indicator: jumping ahead must not bypass required
// fields from earlier steps.
const goToStep = async (target) => {
if (target > 0) {
const valid = await trigger(["title", "message"]);
if (!valid) { setCurrentStep(0); return; }
}
if (target > 1) {
const valid = await trigger(["target_type", "target_id"]);
if (!valid) { setCurrentStep(1); return; }
}
if (target > 2) {
const valid = await trigger(["show_in_sticky", "show_in_notifications", "link_url", "end_date"]);
if (!valid) { setCurrentStep(2); return; }
}
setCurrentStep(target);
};
const saveBroadcast = async (values, { publish = false } = {}) => {
const { link_mode, ...rest } = values;
const payload = {
...rest,
@@ -145,11 +234,24 @@ export default function AddNotificationBroadcast() {
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
link_url: (values.show_in_sticky && link_mode === "link") ? values.link_url.trim() : null,
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
start_date: values.start_date || null,
end_date: values.end_date || null,
createdBy: user?.user_id ?? null,
};
const res = await createBroadcast(payload);
if (res) { bypassOnce(); navigate("/admin/announcements"); }
const created = res?.data?.data ?? null;
if (!created) return;
if (publish) {
const sent = await sendBroadcast(created.broadcast_id);
if (!sent) return;
}
bypassOnce();
navigate("/admin/announcements");
};
return (
@@ -163,147 +265,328 @@ export default function AddNotificationBroadcast() {
<h1 className="text-2xl font-semibold tracking-tight mb-1">New announcement</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">
<StepIndicator steps={STEPS} current={currentStep} onStepClick={goToStep} />
<SectionCard title="Content" description="What admins and/or users will see.">
{templates.length > 0 && (
<form onSubmit={(e) => e.preventDefault()} className="space-y-5">
{/* ── Step 0: Content ── */}
{currentStep === 0 && (
<SectionCard title="Content" description="What admins and/or users will see.">
<div>
<Label className="mb-1.5 block">Load from template</Label>
<Select onValueChange={applyTemplate}>
<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>
)}
{/* ── Step 1: Target ── */}
{currentStep === 1 && (
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
<div>
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
setValue("target_id", null, { shouldDirty: true });
setTargetLabel(null);
}}
>
<SelectTrigger>
<SelectValue placeholder="Optional — start from a saved preset" />
<SelectValue placeholder="Select a target" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.notification_template_id} value={String(t.notification_template_id)}>{t.label}</SelectItem>
{TARGET_TYPE_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">Fills in the title and message below — you can still edit them after.</p>
</div>
)}
<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 announcement when it's sent.">
<div>
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
setValue("target_id", null, { shouldDirty: true });
}}
>
<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, shouldDirty: true })}
/>
<FieldError message={errors.target_id?.message} />
</div>
)}
</SectionCard>
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Checkbox
id="show_in_sticky"
checked={showInSticky === true}
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="show_in_notifications"
checked={showInNotifications === true}
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_notifications" className="cursor-pointer">
Show in Notifications
</Label>
</div>
</div>
</SectionCard>
{showInSticky && (
<SectionCard title="On Open" description="What happens when someone opens this announcement from the sticky banner.">
<div className="flex gap-2">
<Button
type="button"
variant={linkMode !== "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "info", { shouldDirty: true })}
>
Text info only
</Button>
<Button
type="button"
variant={linkMode === "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "link", { shouldDirty: true })}
>
Include a link
</Button>
</div>
{linkMode === "link" ? (
<div>
<Label className="mb-1.5 block">Link URL</Label>
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
<FieldError message={errors.link_url?.message} />
<FieldError message={errors.target_type?.message} />
{targetType && (
<p className="text-xs text-muted-foreground mt-1.5">
Shows an "Open Link" button in the full-content view. Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
{TARGET_TYPE_MAP[targetType]?.description}
</p>
)}
</div>
{needsTarget && (
<div>
<BroadcastTargetPicker
targetType={targetType}
value={targetId}
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
onLabelResolved={setTargetLabel}
/>
<FieldError message={errors.target_id?.message} />
</div>
) : (
<p className="text-xs text-muted-foreground">
The full-content view will show just the title and message, with no action button.
</p>
)}
</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
{/* ── Step 2: Display ── */}
{currentStep === 2 && (
<>
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Checkbox
id="show_in_sticky"
checked={showInSticky === true}
onCheckedChange={(v) => {
const checked = v === true;
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
// Sticky-only announcements vanish forever once dismissed (seen=true drops
// them from the sticky query, show_in_notifications=false hides them from
// the list too) — force the list entry so it stays reachable afterward.
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
}}
/>
<Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="show_in_notifications"
checked={showInNotifications === true}
disabled={showInSticky === true}
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_notifications" className={showInSticky ? "text-muted-foreground" : "cursor-pointer"}>
Show in Notifications
</Label>
</div>
{showInSticky && (
<p className="text-xs text-muted-foreground pl-7">
Required while sticky is on, so it stays visible after being dismissed.
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Start date</Label>
<DateTimePicker
value={startDate || null}
onChange={(iso) => setValue("start_date", iso ?? "", { shouldValidate: true, shouldDirty: true })}
placeholder="Show immediately"
/>
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<DateTimePicker
value={endDate || null}
onChange={(iso) => setValue("end_date", iso ?? "", { shouldValidate: true, shouldDirty: true })}
placeholder="No end date"
/>
<FieldError message={errors.end_date?.message} />
</div>
</div>
{showInSticky && (
<div className="space-y-2 pt-1">
<Label>Sticky banner color</Label>
<div className="flex flex-wrap gap-2">
{TIER_COLOR_OPTIONS.map((opt) => {
const selected = (color || "indigo") === opt.key;
const bg = selected ? shadeColor(opt.swatch, -20) : opt.swatch;
return (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setValue("color", opt.key, { shouldDirty: true })}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${selected ? "scale-105 shadow-md" : "opacity-80 hover:opacity-100"}`}
style={{ backgroundColor: bg, color: getContrastText(bg, opt.key) }}
>
{selected && <Check className="size-3" />}
{opt.label}
</button>
);
})}
</div>
<div
className="w-full flex items-center justify-center gap-3 rounded-md px-3 py-2 text-sm font-semibold"
style={{ backgroundColor: getTierColor(color || "indigo").swatch, color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo") }}
>
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
{linkMode === "link" && (
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
{watch("link_label") || "Open Link"}
</Button>
)}
</div>
</div>
)}
</SectionCard>
{showInSticky && (
<SectionCard title="On Open" description="What happens when someone opens this announcement from the sticky banner.">
<div className="flex gap-2">
<Button
type="button"
variant={linkMode !== "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "info", { shouldDirty: true })}
>
Text info only
</Button>
<Button
type="button"
variant={linkMode === "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "link", { shouldDirty: true })}
>
Include a link
</Button>
</div>
{linkMode === "link" ? (
<div className="space-y-4">
<div>
<Label className="mb-1.5 block">Link URL</Label>
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
<FieldError message={errors.link_url?.message} />
<p className="text-xs text-muted-foreground mt-1.5">
Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
</p>
</div>
<div>
<Label className="mb-1.5 block">Button label</Label>
<Input placeholder="e.g. Shop now" {...register("link_label")} />
<p className="text-xs text-muted-foreground mt-1.5">
Shown as a button right after the title in the sticky banner. Defaults to "Open Link".
</p>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">
The full-content view will show just the title and message, with no action button.
</p>
)}
</SectionCard>
)}
</>
)}
{/* ── Step 3: Review ── */}
{currentStep === 3 && (
<>
<SectionCard title="Content" description="Confirm everything looks right before saving the draft.">
<div className="space-y-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Title</p>
<p className="font-medium">{watch("title") || "—"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Message</p>
<p className="font-medium whitespace-pre-wrap">{watch("message") || "—"}</p>
</div>
</div>
</SectionCard>
<SectionCard title="Target">
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline">{TARGET_TYPE_MAP[targetType]?.label ?? "—"}</Badge>
{needsTarget && (
<span className={targetLabel ? "font-medium" : "text-muted-foreground"}>
{targetLabel ?? (targetId ? "Selected item not found — reselect it" : "No target selected")}
</span>
)}
</div>
</SectionCard>
<SectionCard title="Display">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Sticky Announcements</p>
<p className="font-medium">{showInSticky ? "Shown" : "Hidden"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Notifications</p>
<p className="font-medium">{showInNotifications ? "Shown" : "Hidden"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Start date</p>
<p className="font-medium">{startDate ? format(new Date(startDate), "MMM d, yyyy HH:mm") : "Immediately"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">End date</p>
<p className="font-medium">{endDate ? format(new Date(endDate), "MMM d, yyyy HH:mm") : "No end date"}</p>
</div>
</div>
{showInSticky && (
<div
className="w-full flex items-center justify-center gap-3 rounded-md px-3 py-2 text-sm font-semibold"
style={{ backgroundColor: getTierColor(color || "indigo").swatch, color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo") }}
>
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
{linkMode === "link" && (
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
{watch("link_label") || "Open Link"}
</Button>
)}
</div>
)}
</SectionCard>
{showInSticky && (
<SectionCard title="On Open">
<p className="text-sm">
{linkMode === "link"
? <>Opens <span className="font-medium">{watch("link_url") || "—"}</span> via a “{watch("link_label") || "Open Link"}” button.</>
: "Text info only — no action button."}
</p>
</SectionCard>
)}
</>
)}
{/* ── Step navigation ── */}
<div className="flex items-center justify-between pt-2 pb-6">
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
disabled={loading}
>
<ArrowLeft className="h-4 w-4 mr-2" />
{currentStep === 0 ? "Cancel" : "Back"}
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={() => goToStep(currentStep + 1)}>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : (
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={loading}
onClick={handleSubmit((values) => saveBroadcast(values, { publish: false }))}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save as draft
</Button>
<Button
type="button"
disabled={loading}
onClick={handleSubmit((values) => saveBroadcast(values, { publish: true }))}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Publish now
</Button>
</div>
)}
</div>
</form>
</div>
@@ -1,136 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, House } 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 AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminNotificationTemplateProvider,
useAdminNotificationTemplates,
} from "@/contexts/AdminNotificationTemplateContext";
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 AddNotificationTemplateInner() {
const navigate = useNavigate();
const { loading, createTemplate } = useAdminNotificationTemplates();
const [label, setLabel] = useState("");
const [title, setTitle] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState({});
const validate = () => {
const e = {};
if (!label.trim()) e.label = "Label is required.";
if (!title.trim()) e.title = "Title is required.";
if (!message.trim()) e.message = "Message is required.";
setErrors(e);
return !Object.keys(e).length;
};
const handleCreate = async () => {
if (!validate()) return;
const result = await createTemplate({
label: label.trim(),
title: title.trim(),
message: message.trim(),
});
if (result) navigate("/admin/announcement-templates");
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Add Announcement 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: "Announcements", to: "/admin/announcements" },
{ label: "Templates", to: "/admin/announcement-templates" },
{ label: "Add" },
]} />
</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">
<h1 className="text-xl font-semibold">Add Announcement Template</h1>
<p className="text-sm text-muted-foreground">
Save a reusable title/message preset to load into a new announcement later.
</p>
</div>
</div>
<div className="space-y-5">
<SectionCard title="Template Details">
<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. Scheduled Maintenance" />
<FieldError message={errors.label} />
</div>
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Scheduled maintenance tonight" />
<FieldError message={errors.title} />
</div>
</SectionCard>
<SectionCard title="Message">
<p className="text-xs text-muted-foreground -mt-1">
This is copied straight into the announcement — no placeholders here, this text goes out as-is.
</p>
<Textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={6}
placeholder="Full announcement text"
/>
<FieldError message={errors.message} />
</SectionCard>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Template
</Button>
</div>
</div>
</div>
</div>
</section>
);
}
export default function AddNotificationTemplate() {
return (
<AdminNotificationTemplateProvider>
<AddNotificationTemplateInner />
</AdminNotificationTemplateProvider>
);
}
@@ -5,9 +5,9 @@ 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 { format } from "date-fns";
import { House, Check, ArrowLeft, ArrowRight } from "lucide-react";
import api from "@/utils/api.util";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -18,10 +18,13 @@ 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 { DateTimePicker } from "@/components/ui/date-time-picker";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
// ─── Schema ─────────────────────────────────────────────────────────────────
@@ -34,6 +37,10 @@ const schema = z.object({
show_in_notifications: z.boolean().optional(),
link_mode: z.enum(["info", "link"]).optional(),
link_url: z.string().trim().optional(),
link_label: z.string().trim().optional(),
color: z.string().optional(),
start_date: z.string().optional(),
end_date: z.string().optional(),
}).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({
@@ -58,8 +65,25 @@ const schema = z.object({
path: ["link_url"],
});
}
if (data.start_date && data.end_date && new Date(data.start_date) > new Date(data.end_date)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End date must be after the start date.",
path: ["end_date"],
});
}
});
// ─── Steps config ───────────────────────────────────────────────────────────
const STEPS = [
{ label: "Content", description: "Title & message" },
{ label: "Target", description: "Who receives it" },
{ label: "Display", description: "Where it shows & schedule" },
{ label: "Review", description: "Confirm & save" },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
@@ -81,20 +105,75 @@ function SectionCard({ title, description, children }) {
);
}
function StepIndicator({ steps, current, onStepClick }) {
return (
<div className="flex items-start w-full mb-8">
{steps.flatMap((step, i) => {
const items = [
<button
key={`step-${i}`}
type="button"
onClick={() => onStepClick(i)}
className="flex flex-col items-center gap-1.5 shrink-0 group"
>
<div
className={[
"w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-semibold transition-all group-hover:opacity-80",
i < current
? "bg-primary border-primary text-primary-foreground"
: i === current
? "border-primary text-primary"
: "border-border text-muted-foreground",
].join(" ")}
>
{i < current ? <Check className="h-4 w-4" /> : i + 1}
</div>
<p
className={[
"text-[11px] font-medium text-center leading-tight whitespace-nowrap",
i === current ? "text-foreground" : "text-muted-foreground",
].join(" ")}
>
{step.label}
</p>
</button>,
];
if (i < steps.length - 1) {
items.push(
<div
key={`line-${i}`}
className={[
"flex-1 h-px mt-4 mx-2 shrink",
i < current ? "bg-primary" : "bg-border",
].join(" ")}
/>
);
}
return items;
})}
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function EditNotificationBroadcast() {
const navigate = useNavigate();
const { broadcastId } = useParams();
const { fetchBroadcast, updateBroadcast, loading } = useNotificationBroadcasts();
const { fetchBroadcast, updateBroadcast, sendBroadcast, loading } = useNotificationBroadcasts();
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(0);
const [targetLabel, setTargetLabel] = useState(null);
const [broadcastStatus, setBroadcastStatus] = useState("draft");
const {
register,
handleSubmit,
reset,
watch,
setValue,
trigger,
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
@@ -107,31 +186,24 @@ export default function EditNotificationBroadcast() {
show_in_notifications: true,
link_mode: "info",
link_url: "",
link_label: "",
color: "indigo",
start_date: "",
end_date: "",
},
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const [templates, setTemplates] = useState([]);
useEffect(() => {
api.get("/admin/announcement-templates")
.then(({ data }) => setTemplates((data.data ?? []).filter((t) => !t.is_system)))
.catch(() => {});
}, []);
const applyTemplate = (id) => {
const tpl = templates.find((t) => String(t.notification_template_id) === id);
if (!tpl) return;
setValue("title", tpl.title ?? "", { shouldValidate: true, shouldDirty: true });
setValue("message", tpl.message ?? "", { shouldValidate: true, shouldDirty: true });
};
const targetType = watch("target_type");
const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const showInSticky = watch("show_in_sticky");
const showInNotifications = watch("show_in_notifications");
const linkMode = watch("link_mode");
const color = watch("color");
const startDate = watch("start_date");
const endDate = watch("end_date");
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -146,21 +218,48 @@ export default function EditNotificationBroadcast() {
const b = res?.data?.data ?? null;
if (!b) return;
// Self-heals broadcasts saved before sticky-only was disallowed: sticky
// without notifications made the announcement unrecoverable once dismissed.
const stickyOn = b.show_in_sticky ?? false;
reset({
title: b.title ?? "",
message: b.message ?? "",
target_type: b.target_type ?? undefined,
target_id: b.target_id ?? null,
show_in_sticky: b.show_in_sticky ?? false,
show_in_notifications: b.show_in_notifications ?? true,
show_in_sticky: stickyOn,
show_in_notifications: stickyOn ? true : (b.show_in_notifications ?? true),
link_mode: b.link_url ? "link" : "info",
link_url: b.link_url ?? "",
link_label: b.link_label ?? "",
color: b.color ?? "indigo",
start_date: b.start_date ?? "",
end_date: b.end_date ?? "",
});
setBroadcastStatus(b.status ?? "draft");
setCurrentStep(0);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [broadcastId]);
const onSubmit = async (values) => {
// Guards the step indicator: jumping ahead must not bypass required
// fields from earlier steps.
const goToStep = async (target) => {
if (target > 0) {
const valid = await trigger(["title", "message"]);
if (!valid) { setCurrentStep(0); return; }
}
if (target > 1) {
const valid = await trigger(["target_type", "target_id"]);
if (!valid) { setCurrentStep(1); return; }
}
if (target > 2) {
const valid = await trigger(["show_in_sticky", "show_in_notifications", "link_url", "end_date"]);
if (!valid) { setCurrentStep(2); return; }
}
setCurrentStep(target);
};
const saveBroadcast = async (values, { publish = false } = {}) => {
const { link_mode, ...rest } = values;
const payload = {
...rest,
@@ -168,11 +267,23 @@ export default function EditNotificationBroadcast() {
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
link_url: (values.show_in_sticky && link_mode === "link") ? values.link_url.trim() : null,
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
start_date: values.start_date || null,
end_date: values.end_date || null,
updatedBy: user?.user_id ?? null,
};
const res = await updateBroadcast(broadcastId, payload);
if (res) { bypassOnce(); navigate("/admin/announcements"); }
if (!res) return;
if (publish) {
const sent = await sendBroadcast(broadcastId);
if (!sent) return;
}
bypassOnce();
navigate("/admin/announcements");
};
return (
@@ -184,149 +295,343 @@ export default function EditNotificationBroadcast() {
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit announcement</h1>
<p className="text-sm text-muted-foreground mb-6">Only draft announcements can be edited.</p>
<p className="text-sm text-muted-foreground mb-6">
{broadcastStatus === "sent"
? "This announcement has already been sent — changes apply immediately to anyone currently seeing it."
: "It's saved as a draft until you send it."}
</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<StepIndicator steps={STEPS} current={currentStep} onStepClick={goToStep} />
<SectionCard title="Content" description="What admins and/or users will see.">
{templates.length > 0 && (
<form onSubmit={(e) => e.preventDefault()} className="space-y-5">
{/* ── Step 0: Content ── */}
{currentStep === 0 && (
<SectionCard title="Content" description="What admins and/or users will see.">
<div>
<Label className="mb-1.5 block">Load from template</Label>
<Select onValueChange={applyTemplate}>
<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>
)}
{/* ── Step 1: Target ── */}
{currentStep === 1 && (
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
<div>
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
setValue("target_id", null, { shouldDirty: true });
setTargetLabel(null);
}}
>
<SelectTrigger>
<SelectValue placeholder="Optional — start from a saved preset" />
<SelectValue placeholder="Select a target" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.notification_template_id} value={String(t.notification_template_id)}>{t.label}</SelectItem>
{TARGET_TYPE_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">Fills in the title and message below — you can still edit them after.</p>
</div>
)}
<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 announcement when it's sent.">
<div>
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
setValue("target_id", null, { shouldDirty: true });
}}
>
<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, shouldDirty: true })}
/>
<FieldError message={errors.target_id?.message} />
</div>
)}
</SectionCard>
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Checkbox
id="show_in_sticky"
checked={showInSticky === true}
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="show_in_notifications"
checked={showInNotifications === true}
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_notifications" className="cursor-pointer">
Show in Notifications
</Label>
</div>
</div>
</SectionCard>
{showInSticky && (
<SectionCard title="On Open" description="What happens when someone opens this announcement from the sticky banner.">
<div className="flex gap-2">
<Button
type="button"
variant={linkMode !== "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "info", { shouldDirty: true })}
>
Text info only
</Button>
<Button
type="button"
variant={linkMode === "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "link", { shouldDirty: true })}
>
Include a link
</Button>
</div>
{linkMode === "link" ? (
<div>
<Label className="mb-1.5 block">Link URL</Label>
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
<FieldError message={errors.link_url?.message} />
<FieldError message={errors.target_type?.message} />
{targetType && (
<p className="text-xs text-muted-foreground mt-1.5">
Shows an "Open Link" button in the full-content view. Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
{TARGET_TYPE_MAP[targetType]?.description}
</p>
)}
</div>
{needsTarget && (
<div>
<BroadcastTargetPicker
targetType={targetType}
value={targetId}
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
onLabelResolved={setTargetLabel}
/>
<FieldError message={errors.target_id?.message} />
</div>
) : (
<p className="text-xs text-muted-foreground">
The full-content view will show just the title and message, with no action button.
</p>
)}
</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
{/* ── Step 2: Display ── */}
{currentStep === 2 && (
<>
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Checkbox
id="show_in_sticky"
checked={showInSticky === true}
onCheckedChange={(v) => {
const checked = v === true;
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
// Sticky-only announcements vanish forever once dismissed (seen=true drops
// them from the sticky query, show_in_notifications=false hides them from
// the list too) — force the list entry so it stays reachable afterward.
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
}}
/>
<Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="show_in_notifications"
checked={showInNotifications === true}
disabled={showInSticky === true}
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_notifications" className={showInSticky ? "text-muted-foreground" : "cursor-pointer"}>
Show in Notifications
</Label>
</div>
{showInSticky && (
<p className="text-xs text-muted-foreground pl-7">
Required while sticky is on, so it stays visible after being dismissed.
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Start date</Label>
<DateTimePicker
value={startDate || null}
onChange={(iso) => setValue("start_date", iso ?? "", { shouldValidate: true, shouldDirty: true })}
placeholder="Show immediately"
/>
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<DateTimePicker
value={endDate || null}
onChange={(iso) => setValue("end_date", iso ?? "", { shouldValidate: true, shouldDirty: true })}
placeholder="No end date"
/>
<FieldError message={errors.end_date?.message} />
</div>
</div>
{showInSticky && (
<div className="space-y-2 pt-1">
<Label>Sticky banner color</Label>
<div className="flex flex-wrap gap-2">
{TIER_COLOR_OPTIONS.map((opt) => {
const selected = (color || "indigo") === opt.key;
const bg = selected ? shadeColor(opt.swatch, -20) : opt.swatch;
return (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setValue("color", opt.key, { shouldDirty: true })}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${selected ? "scale-105 shadow-md" : "opacity-80 hover:opacity-100"}`}
style={{ backgroundColor: bg, color: getContrastText(bg, opt.key) }}
>
{selected && <Check className="size-3" />}
{opt.label}
</button>
);
})}
</div>
<div
className="w-full flex items-center justify-center gap-3 rounded-md px-3 py-2 text-sm font-semibold"
style={{ backgroundColor: getTierColor(color || "indigo").swatch, color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo") }}
>
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
{linkMode === "link" && (
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
{watch("link_label") || "Open Link"}
</Button>
)}
</div>
</div>
)}
</SectionCard>
{showInSticky && (
<SectionCard title="On Open" description="What happens when someone opens this announcement from the sticky banner.">
<div className="flex gap-2">
<Button
type="button"
variant={linkMode !== "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "info", { shouldDirty: true })}
>
Text info only
</Button>
<Button
type="button"
variant={linkMode === "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "link", { shouldDirty: true })}
>
Include a link
</Button>
</div>
{linkMode === "link" ? (
<div className="space-y-4">
<div>
<Label className="mb-1.5 block">Link URL</Label>
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
<FieldError message={errors.link_url?.message} />
<p className="text-xs text-muted-foreground mt-1.5">
Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
</p>
</div>
<div>
<Label className="mb-1.5 block">Button label</Label>
<Input placeholder="e.g. Shop now" {...register("link_label")} />
<p className="text-xs text-muted-foreground mt-1.5">
Shown as a button right after the title in the sticky banner. Defaults to "Open Link".
</p>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">
The full-content view will show just the title and message, with no action button.
</p>
)}
</SectionCard>
)}
</>
)}
{/* ── Step 3: Review ── */}
{currentStep === 3 && (
<>
<SectionCard title="Content" description="Confirm everything looks right before saving.">
<div className="space-y-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Title</p>
<p className="font-medium">{watch("title") || "—"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Message</p>
<p className="font-medium whitespace-pre-wrap">{watch("message") || "—"}</p>
</div>
</div>
</SectionCard>
<SectionCard title="Target">
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline">{TARGET_TYPE_MAP[targetType]?.label ?? "—"}</Badge>
{needsTarget && (
<span className={targetLabel ? "font-medium" : "text-muted-foreground"}>
{targetLabel ?? (targetId ? "Selected item not found — reselect it" : "No target selected")}
</span>
)}
</div>
</SectionCard>
<SectionCard title="Display">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Sticky Announcements</p>
<p className="font-medium">{showInSticky ? "Shown" : "Hidden"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Notifications</p>
<p className="font-medium">{showInNotifications ? "Shown" : "Hidden"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Start date</p>
<p className="font-medium">{startDate ? format(new Date(startDate), "MMM d, yyyy HH:mm") : "Immediately"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">End date</p>
<p className="font-medium">{endDate ? format(new Date(endDate), "MMM d, yyyy HH:mm") : "No end date"}</p>
</div>
</div>
{showInSticky && (
<div
className="w-full flex items-center justify-center gap-3 rounded-md px-3 py-2 text-sm font-semibold"
style={{ backgroundColor: getTierColor(color || "indigo").swatch, color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo") }}
>
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
{linkMode === "link" && (
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
{watch("link_label") || "Open Link"}
</Button>
)}
</div>
)}
</SectionCard>
{showInSticky && (
<SectionCard title="On Open">
<p className="text-sm">
{linkMode === "link"
? <>Opens <span className="font-medium">{watch("link_url") || "—"}</span> via a “{watch("link_label") || "Open Link"}” button.</>
: "Text info only — no action button."}
</p>
</SectionCard>
)}
</>
)}
{/* ── Step navigation ── */}
<div className="flex items-center justify-between pt-2 pb-6">
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
disabled={loading}
>
<ArrowLeft className="h-4 w-4 mr-2" />
{currentStep === 0 ? "Cancel" : "Back"}
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={() => goToStep(currentStep + 1)}>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : broadcastStatus === "sent" ? (
<Button
type="button"
disabled={loading}
onClick={handleSubmit((values) => saveBroadcast(values, { publish: false }))}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save changes
</Button>
) : (
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={loading}
onClick={handleSubmit((values) => saveBroadcast(values, { publish: false }))}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save as draft
</Button>
<Button
type="button"
disabled={loading}
onClick={handleSubmit((values) => saveBroadcast(values, { publish: true }))}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Publish now
</Button>
</div>
)}
</div>
</form>
</div>
@@ -1,272 +0,0 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Lock, Send, Clock3, Trash2 } 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 {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminNotificationTemplateProvider,
useAdminNotificationTemplates,
} from "@/contexts/AdminNotificationTemplateContext";
import { NOTIFICATION_TEMPLATE_PLACEHOLDERS } from "@/data/notificationTemplatePlaceholders.data";
import { STATUS_META, hasPendingChanges } from "@/data/notificationTemplateStatus.data";
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 EditNotificationTemplateInner() {
const navigate = useNavigate();
const { id } = useParams();
const { template, loading, fetchTemplate, updateTemplate, deleteTemplate } = useAdminNotificationTemplates();
const [label, setLabel] = useState("");
const [title, setTitle] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState({});
const [confirmDelete, setConfirmDelete] = useState(false);
const isCustom = template && !template.is_system;
useEffect(() => {
if (id) fetchTemplate(id);
}, [id]);
useEffect(() => {
if (template) {
setLabel(template.label ?? "");
// Prefer whatever's pending (unpublished) over the live version, so
// reopening a template with pending changes resumes editing them.
setTitle(template.draft_title ?? template.title ?? "");
setMessage(template.draft_message ?? template.message ?? "");
}
}, [template]);
const status = STATUS_META[template?.status] ?? STATUS_META.draft;
const pending = hasPendingChanges(template);
const knownPlaceholders = NOTIFICATION_TEMPLATE_PLACEHOLDERS[template?.type] ?? null;
const validate = () => {
const e = {};
if (!label.trim()) e.label = "Label is required.";
if (!title.trim()) e.title = "Title is required.";
if (!message.trim()) e.message = "Message is required.";
setErrors(e);
return !Object.keys(e).length;
};
const handleSave = async (publish) => {
if (!validate()) return;
const result = await updateTemplate(id, {
label: label.trim(),
title: title.trim(),
message: message.trim(),
publish,
});
if (result) navigate("/admin/announcement-templates");
};
const handleDelete = async () => {
const result = await deleteTemplate(id);
setConfirmDelete(false);
if (result) navigate("/admin/announcement-templates");
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Edit Announcement 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: "Announcements", to: "/admin/announcements" },
{ label: "Templates", to: "/admin/announcement-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 Announcement Template</h1>
{template && !isCustom && (
<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 template's title and message.</p>
</div>
</div>
{pending && !isCustom && (
<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 used is the last one you published. Press <strong>Publish</strong> below to
apply these edits, or <strong>Save as Draft</strong> to keep working without publishing.
</p>
</div>
)}
{!isCustom && (
<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> notification — code fires it by referencing this exact type,
so the type is locked. Label, title and message are still fully editable.
</p>
</div>
)}
<div className="space-y-5">
<SectionCard title="Template Details">
{!isCustom && (
<div className="space-y-1.5">
<Label>Type</Label>
<Input value={template?.type ?? ""} disabled />
<p className="text-xs text-muted-foreground">Cannot be changed — this is what code looks up.</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. Tasks Overdue (Admin)" />
<FieldError message={errors.label} />
</div>
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Tasks Overdue" />
<FieldError message={errors.title} />
</div>
</SectionCard>
<SectionCard>
<div className="flex items-center justify-between border-b pb-3">
<p className="text-sm font-semibold">Message</p>
</div>
<p className="text-xs text-muted-foreground -mt-1">
{isCustom
? "Plain text only — this is copied straight into the announcement as-is."
: (<>Plain text only — no HTML, no conditional logic, just straight{" "}
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens.</>)}
</p>
{!isCustom && (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>
)}
<Textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={6}
className="font-mono text-xs"
placeholder="{{count}} {{task_word}} automatically marked as overdue."
/>
<FieldError message={errors.message} />
</SectionCard>
<div className="flex items-center justify-between gap-3">
{isCustom ? (
<Button type="button" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => setConfirmDelete(true)} disabled={loading}>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</Button>
) : <span />}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
{isCustom ? (
<Button type="button" onClick={() => handleSave(false)} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save
</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" />}
Publish
</Button>
</>
)}
</div>
</div>
</div>
</div>
</div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this template?</AlertDialogTitle>
<AlertDialogDescription>
"{template?.label}" will be permanently removed. It won't affect any announcements already sent.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
export default function EditNotificationTemplate() {
return (
<AdminNotificationTemplateProvider>
<EditNotificationTemplateInner />
</AdminNotificationTemplateProvider>
);
}
@@ -2,11 +2,13 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, FileText, Archive } from "lucide-react";
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Archive, ImagePlus, X } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { TablePagination } from "@/components/generic/Table/TablePagination";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -18,15 +20,29 @@ import {
} from "@/components/ui/alert-dialog";
import { TARGET_TYPE_MAP, BROADCAST_STATUSES, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
import { resolveAssetSrc } from "@/utils/media.util";
export default function NotificationBroadcastList() {
const navigate = useNavigate();
const { broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast } = useNotificationBroadcasts();
const { user } = useAuth();
const {
broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast,
stickyBannerSetting, fetchStickyBannerSetting, updateStickyBannerSetting,
} = useNotificationBroadcasts();
const [statusFilter, setStatusFilter] = useState("all");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [limit, setLimit] = useState(12);
const [pickerOpen, setPickerOpen] = useState(false);
const bannerAsset = stickyBannerSetting?.image ?? null;
const bannerImageUrl = bannerAsset ? resolveAssetSrc(bannerAsset) : null;
useEffect(() => {
fetchStickyBannerSetting();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function buildFilters() {
const filters = [];
@@ -73,10 +89,6 @@ export default function NotificationBroadcastList() {
<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/announcement-templates")}>
<FileText className="size-4" />
Templates
</Button>
<Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}>
<Archive className="size-4" />
Archived
@@ -95,6 +107,24 @@ export default function NotificationBroadcastList() {
<StatCard label="Sent" value={sentCount} tone="success" />
</div>
{/* ── Sticky banner image (shared across all active announcements) ── */}
<div className="bg-background rounded-lg border p-4 flex items-center gap-4">
<div className="w-40 shrink-0">
<StickyBannerPicker
selectedAsset={bannerAsset}
imageUrl={bannerImageUrl}
onPick={() => setPickerOpen(true)}
onRemove={() => updateStickyBannerSetting({ image_asset_id: null, updatedBy: user?.user_id ?? null })}
/>
</div>
<div>
<p className="text-sm font-medium">Sticky banner image</p>
<p className="text-xs text-muted-foreground mt-0.5">
Shown in the details dialog for every currently-active sticky announcement (up to 3 share this one image).
</p>
</div>
</div>
{/* ── Filters ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={statusFilter} onValueChange={setStatusFilter}>
@@ -110,11 +140,11 @@ export default function NotificationBroadcastList() {
</Select>
<div className="flex items-center gap-2 flex-1 min-w-[160px]">
<div className="relative flex-1">
<div className="relative w-64">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search announcements..."
className="pl-8 bg-background"
className="pl-8 bg-background text-sm"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
@@ -163,12 +193,65 @@ export default function NotificationBroadcastList() {
)}
</div>
</div>
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => {
updateStickyBannerSetting({ image_asset_id: asset.asset_id, updatedBy: user?.user_id ?? null });
}}
/>
</section>
);
}
// ─── Stat card ──────────────────────────────────────────────────────────────
// ─── Sticky banner image picker ────────────────────────────────────────────
function StickyBannerPicker({ selectedAsset, imageUrl, onPick, onRemove }) {
if (!selectedAsset) {
return (
<button
type="button"
onClick={onPick}
className="w-full aspect-video rounded-lg border border-dashed flex flex-col items-center justify-center gap-1.5 text-muted-foreground hover:bg-muted/50 transition-colors"
>
<ImagePlus className="size-4" />
<span className="text-xs">Select an image</span>
</button>
);
}
return (
<div className="relative rounded-lg overflow-hidden border aspect-video group">
<img
src={imageUrl || resolveAssetSrc(selectedAsset)}
alt={selectedAsset.display_name}
className="w-full h-full object-cover cursor-pointer"
onClick={onPick}
/>
<div
onClick={onPick}
className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center cursor-pointer"
>
<span className="text-white text-xs opacity-0 group-hover:opacity-100">Change image</span>
</div>
<Button
type="button"
variant="secondary"
size="icon"
className="absolute top-1 right-1 size-6"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
aria-label="Remove image"
>
<X className="size-3.5" />
</Button>
</div>
);
}
function StatCard({ label, value, tone = "default" }) {
const toneClass = {
default: "text-foreground",
@@ -238,11 +321,9 @@ function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
</AlertDialogContent>
</AlertDialog>
)}
{isDraft && (
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
<Edit className="size-3.5" />
</Button>
)}
<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">
@@ -1,242 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Pencil, Bell, Lock, Send, Clock3, Plus, Trash2 } 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 {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminNotificationTemplateProvider,
useAdminNotificationTemplates,
} from "@/contexts/AdminNotificationTemplateContext";
import { NOTIFICATION_TEMPLATE_TYPES, getNotificationTemplateType } from "@/data/notificationTemplateTypes.data";
import { STATUS_META, hasPendingChanges } from "@/data/notificationTemplateStatus.data";
import { cn } from "@/lib/utils";
function TemplateCard({ item, onEdit, onDelete }) {
const typeMeta = getNotificationTemplateType(item.notify_type);
const TypeIcon = typeMeta?.icon ?? Bell;
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">
<TypeIcon className="h-4.5 w-4.5 text-muted-foreground" />
</div>
<div className="flex items-center gap-0.5">
{!item.is_system && (
<Button type="button" variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</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.title || item.draft_title || "No title yet"}</span>
</p>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{typeMeta && (
<Badge variant="outline" className="gap-1 text-[11px]">
<typeMeta.icon className="h-3 w-3" /> {typeMeta.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>
</div>
);
}
function NotificationTemplatesInner() {
const navigate = useNavigate();
const { templates, loading, fetchTemplates, deleteTemplate } = useAdminNotificationTemplates();
const [activeType, setActiveType] = useState("all");
const [deleteTarget, setDeleteTarget] = useState(null);
useEffect(() => { fetchTemplates(); }, []);
const handleDelete = async () => {
if (!deleteTarget) return;
await deleteTemplate(deleteTarget.notification_template_id);
setDeleteTarget(null);
};
const filtered = useMemo(
() => activeType === "all" ? templates : templates.filter((t) => t.notify_type === activeType),
[templates, activeType]
);
const typesInUse = useMemo(
() => NOTIFICATION_TEMPLATE_TYPES.filter((t) => templates.some((tpl) => tpl.notify_type === t.value)),
[templates]
);
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Announcement 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: "Announcements", to: "/admin/announcements" },
{ label: "Templates" },
]} />
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold">Announcement Templates</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Title and message wording for every automated notification 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>
<Button type="button" onClick={() => navigate("/admin/announcement-templates/add")} className="gap-1.5">
<Plus className="h-4 w-4" /> Add Template
</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" />
<div className="text-xs text-muted-foreground space-y-1">
<p>
<strong>System</strong> templates are locked — code fires them by referencing their exact
type, so only the title and message wording is editable, never the type itself, and they
can't be deleted.
</p>
<p>
<strong>Custom</strong> templates (no lock icon) are reusable title/message presets you
create — pick one from the "Load from template" dropdown when composing a new announcement
to skip retyping recurring wording. You can freely create, edit, and delete these.
</p>
<p>
<strong>Draft vs. Sent</strong> (system templates only): a <strong>Sent</strong> template
is the version actually used for real notifications right now. Editing a Sent template
doesn't change what goes out immediately — it's held as a pending change until you press{" "}
<strong>Publish</strong> again.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 mb-5">
<Button
type="button" size="sm" variant={activeType === "all" ? "secondary" : "outline"}
onClick={() => setActiveType("all")}
>
All ({templates.length})
</Button>
{typesInUse.map((t) => {
const Icon = t.icon;
const count = templates.filter((tpl) => tpl.notify_type === t.value).length;
return (
<Button
key={t.value}
type="button" size="sm"
variant={activeType === t.value ? "secondary" : "outline"}
onClick={() => setActiveType(t.value)}
className="gap-1.5"
>
<Icon className="h-3.5 w-3.5" /> {t.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 notification 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.notification_template_id}
item={item}
onEdit={(t) => navigate(`/admin/announcement-templates/${t.notification_template_id}/edit`)}
onDelete={setDeleteTarget}
/>
))}
</div>
)}
</div>
</div>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this template?</AlertDialogTitle>
<AlertDialogDescription>
"{deleteTarget?.label}" will be permanently removed. It won't affect any announcements already sent.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
export default function NotificationTemplates() {
return (
<AdminNotificationTemplateProvider>
<NotificationTemplatesInner />
</AdminNotificationTemplateProvider>
);
}
@@ -169,8 +169,8 @@ export default function ViewNotificationBroadcast() {
</div>
</div>
{isDraft && (
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-2 shrink-0">
{isDraft && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
@@ -191,12 +191,12 @@ export default function ViewNotificationBroadcast() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button size="sm" onClick={() => navigate(`/admin/announcements/${broadcastId}/edit`)}>
<Edit className="size-4" />
Edit
</Button>
</div>
)}
)}
<Button size="sm" onClick={() => navigate(`/admin/announcements/${broadcastId}/edit`)}>
<Edit className="size-4" />
Edit
</Button>
</div>
</div>
{/* Underline tabs */}
@@ -11,7 +11,7 @@ import {
Milestone, Navigation, Sunrise,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
import { Badge } from "@/components/ui/badge";
export const BADGE_ICON_OPTIONS = [
@@ -279,14 +279,15 @@ function EditTierCategoryInner({ isAdd }) {
<div className="flex flex-wrap gap-2">
{TIER_COLOR_OPTIONS.map((opt) => {
const selected = color === opt.key;
const bg = selected ? shadeColor(opt.swatch, -20) : opt.swatch;
return (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setColor(opt.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border-2 transition-all ${selected ? "border-foreground scale-105" : "border-transparent opacity-70 hover:opacity-100"}`}
style={{ backgroundColor: opt.swatch, color: "#fff" }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${selected ? "scale-105 shadow-md" : "opacity-80 hover:opacity-100"}`}
style={{ backgroundColor: bg, color: getContrastText(bg, opt.key) }}
>
{selected && <Check className="size-3" />}
{opt.label}
-38
View File
@@ -115,9 +115,6 @@ import EditAdvertisement from '../pages/advertisements/EditAdvertisement'
import ViewAdvertisement from '../pages/advertisements/ViewAdvertisement'
import ArchivedAdvertisementList from '../pages/advertisements/ArchivedAdvertisementList'
// Achievements
import Achievements from '../pages/achievements/Achievements'
import { AddAchievement, EditAchievement } from '../pages/achievements/EditAchievement'
// Notification Broadcasts
import NotificationBroadcastList from '../pages/notifications/NotificationBroadcastList'
@@ -125,9 +122,6 @@ import AddNotificationBroadcast from '../pages/notifications/AddNotificationBroa
import EditNotificationBroadcast from '../pages/notifications/EditNotificationBroadcast'
import ViewNotificationBroadcast from '../pages/notifications/ViewNotificationBroadcast'
import Jobs from '../pages/jobs/Jobs'
import NotificationTemplates from '../pages/notifications/NotificationTemplates'
import AddNotificationTemplate from '../pages/notifications/AddNotificationTemplate'
import EditNotificationTemplate from '../pages/notifications/EditNotificationTemplate'
import ArchivedNotificationBroadcastList from '../pages/notifications/ArchivedNotificationBroadcastList'
// Activity
@@ -358,17 +352,6 @@ export const AdminRoutes = {
]
},
// Achievements
{
path: 'achievements',
element: <Outlet />,
children: [
{ index: true, element: <Achievements /> },
{ path: 'add', element: <AddAchievement /> },
{ path: ':id/edit', element: <EditAchievement /> },
]
},
// Jobs (cron scheduling for announcement/notification jobs)
{ path: 'jobs', element: <Jobs /> },
@@ -386,16 +369,6 @@ export const AdminRoutes = {
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
]
},
{
path: 'announcement-templates',
element: <Outlet />,
children: [
{ index: true, element: <NotificationTemplates /> },
{ path: 'add', element: <AddNotificationTemplate /> },
{ path: ':id/edit', element: <EditNotificationTemplate /> },
]
},
// Backwards-compatible aliases (keep old URLs working)
{
path: 'notifications',
@@ -409,17 +382,6 @@ export const AdminRoutes = {
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
]
},
{
path: 'notification-templates',
element: <Outlet />,
children: [
{ index: true, element: <NotificationTemplates /> },
{ path: 'add', element: <AddNotificationTemplate /> },
{ path: ':id/edit', element: <EditNotificationTemplate /> },
]
},
// Activity Feed
{ path: 'activity', element: <ActivityFeed /> },
+10 -18
View File
@@ -3,10 +3,11 @@
// no lesson_count/quiz_id of its own — shows unit_count instead (how many Units
// it's attached to).
import { Timer, LockIcon, Layers } from "lucide-react";
import { Timer, LockIcon, Layers, Tag } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util";
function formatDuration(seconds = 0) {
if (!seconds) return null;
@@ -17,11 +18,13 @@ function formatDuration(seconds = 0) {
return `${m}m`;
}
export const LessonCard = ({ lesson, onViewDetails }) => {
export const LessonCard = ({ lesson, tierMap = {}, onViewDetails }) => {
const locked = lesson.is_locked;
const duration = formatDuration(lesson.duration_seconds);
const unitCount = Number(lesson.unit_count ?? 0);
const courses = lesson.courses ?? [];
const slug = cheapestTierSlug(courses.map((c) => c.subscription), tierMap);
const { label, cls } = resolveTierBadge(slug, tierMap);
return (
<div
@@ -35,23 +38,12 @@ export const LessonCard = ({ lesson, onViewDetails }) => {
onClick={() => onViewDetails(lesson)}
>
<div className="flex flex-wrap gap-1.5">
<Badge className={cls}>
{locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
{locked && (
<Badge variant="secondary">
<LockIcon className="size-3" /> Locked
</Badge>
)}
{unitCount > 0 ? (
<Badge variant="outline">
<Layers className="size-3" /> In {unitCount} unit{unitCount === 1 ? "" : "s"}
{courses[0] && (
<>
{" "}· <span className="truncate max-w-[120px] inline-block align-bottom">{courses[0].title}</span>
{courses.length > 1 ? ` +${courses.length - 1}` : ""}
</>
)}
</Badge>
) : (
!locked && <Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
<Badge variant="outline" className="text-muted-foreground">Locked</Badge>
)}
</div>
+66 -77
View File
@@ -1,96 +1,85 @@
// UnitCard — grid card for a standalone Unit. Shared by UnitsList.jsx and
// Dashboard.jsx's "Featured Units" section.
import { Timer, LockIcon, Layers, BookOpen, ClipboardList } from "lucide-react";
import { Timer, LockIcon, Layers, Tag } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util";
function formatDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`;
return `${m}m`;
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`;
return `${m}m`;
}
export const UnitCard = ({ unit, onViewDetails }) => {
const locked = unit.is_locked;
const duration = formatDuration(unit.duration_seconds);
const lessonCount = Number(unit.lesson_count ?? 0);
const courseCount = Number(unit.course_count ?? 0);
const courses = unit.courses ?? [];
export const UnitCard = ({ unit, tierMap = {}, onViewDetails }) => {
const locked = unit.is_locked;
const duration = formatDuration(unit.duration_seconds);
const lessonCount = Number(unit.lesson_count ?? 0);
const courses = unit.courses ?? [];
const slug = cheapestTierSlug([unit.subscription, ...courses.map((c) => c.subscription)], tierMap);
const { label, cls } = resolveTierBadge(slug, tierMap);
return (
<div
className={cn(
"bg-card rounded-2xl border p-4 flex flex-col gap-2.5 transition-all cursor-pointer group",
"hover:shadow-sm",
locked
? "opacity-80 hover:opacity-100 hover:border-muted-foreground/40"
: "hover:bg-muted/60 dark:hover:border-blue-500"
)}
onClick={() => onViewDetails(unit)}
>
<div className="flex flex-wrap gap-1.5">
{locked && (
<Badge variant="secondary">
<LockIcon className="size-3" /> Locked
</Badge>
)}
{courseCount > 0 ? (
<Badge variant="outline">
<BookOpen className="size-3" />
<span className="truncate max-w-[140px] inline-block align-bottom">{courses[0]?.title ?? "Course"}</span>
{courseCount > 1 ? ` +${courseCount - 1}` : ""}
</Badge>
) : (
!locked && <Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
)}
{unit.quiz_id && (
<Badge variant="outline"><ClipboardList className="size-3" /> Quiz</Badge>
)}
</div>
return (
<div
className={cn(
"bg-card rounded-2xl border p-4 flex flex-col gap-2.5 transition-all cursor-pointer group",
"hover:shadow-sm",
locked
? "opacity-80 hover:opacity-100 hover:border-muted-foreground/40"
: "hover:bg-muted/60 dark:hover:border-blue-500"
)}
onClick={() => onViewDetails(unit)}
>
<div className="flex flex-wrap gap-1.5">
<Badge className={cls}>
{locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
</div>
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium leading-snug line-clamp-3 transition-colors group-hover:text-blue-700 dark:group-hover:text-blue-400">
{unit.title}
</h1>
{unit.description && (
<p className="text-sm leading-relaxed line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400">
{unit.description}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium leading-snug line-clamp-3 transition-colors group-hover:text-blue-700 dark:group-hover:text-blue-400">
{unit.title}
</h1>
{unit.description && (
<p className="text-sm leading-relaxed line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400">
{unit.description}
</p>
)}
</div>
<div className="flex items-center justify-between pt-2 mt-auto border-t">
<div className={`flex items-center gap-3 text-sm [&_svg]:size-4 ${locked ? "text-muted-foreground" : "text-card-foreground group-hover:text-blue-700 dark:group-hover:text-blue-400"}`}>
<div className="flex items-center gap-1">
<Layers /> {lessonCount} {lessonCount === 1 ? "Lesson" : "Lessons"}
</div>
<div className="flex items-center gap-1">
<Timer /> {duration ?? "—"}
</div>
</div>
{locked && (
<span className="text-xs text-muted-foreground">Upgrade to unlock</span>
)}
</div>
<div className="flex items-center justify-between pt-2 mt-auto border-t">
<div className={`flex items-center gap-3 text-sm [&_svg]:size-4 ${locked ? "text-muted-foreground" : "text-card-foreground group-hover:text-blue-700 dark:group-hover:text-blue-400"}`}>
<div className="flex items-center gap-1">
<Layers /> {lessonCount} {lessonCount === 1 ? "Lesson" : "Lessons"}
</div>
<div className="flex items-center gap-1">
<Timer /> {duration ?? "—"}
</div>
</div>
);
{locked && (
<span className="text-xs text-muted-foreground">Upgrade to unlock</span>
)}
</div>
</div>
);
};
export const UnitCardSkeleton = () => (
<div className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5">
<div className="flex gap-1.5">
<Skeleton className="h-5 w-20 rounded-full" />
</div>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="pt-2 mt-auto border-t">
<Skeleton className="h-4 w-24" />
</div>
<div className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5">
<div className="flex gap-1.5">
<Skeleton className="h-5 w-20 rounded-full" />
</div>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="pt-2 mt-auto border-t">
<Skeleton className="h-4 w-24" />
</div>
</div>
);
@@ -0,0 +1,104 @@
// modules/client/pages/AdvertisementLandingPage.jsx
//
// Destination for an advertisement's own click-through when no redirect_link
// was set — the internal page authored in the "Page Builder" wizard step
// (Step 3 of Add Advertisement). Resolved by uuid via
// GET /api/client/advertisements/uuid/:uuid.
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Megaphone, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { PageMeta } from "@/contexts/MetadataContext";
import { resolveAssetSrc } from "@/utils/media.util";
import api from "@/utils/api.util";
export default function AdvertisementLandingPage() {
const { uuid } = useParams();
const navigate = useNavigate();
const [ad, setAd] = useState(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
setLoading(true);
setNotFound(false);
api.get(`/client/advertisements/uuid/${uuid}`)
.then(({ data }) => setAd(data?.data?.data ?? null))
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [uuid]);
if (loading) {
return (
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 space-y-4">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-56 w-full rounded-lg" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
);
}
if (notFound || !ad) {
return (
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 flex flex-col items-center text-center gap-3 py-16">
<Megaphone className="size-8 text-muted-foreground" />
<p className="font-medium">This advertisement is no longer available.</p>
<Button variant="outline" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" /> Go back
</Button>
</div>
);
}
const page = ad.landing_page ?? {};
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const links = Array.isArray(page.links) ? page.links : [];
return (
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 space-y-6 pb-16">
<PageMeta title={page.title ? `${page.title} - STARR` : undefined} description={page.description} />
<Button variant="ghost" size="sm" className="w-fit -ml-2" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" /> Back
</Button>
{imageSrc && (
<div className="rounded-lg overflow-hidden border h-56 sm:h-72">
<img src={imageSrc} alt={page.title || ad.headline || "Advertisement"} className="w-full h-full object-cover" />
</div>
)}
<div className="space-y-2">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">{page.title || ad.headline || "Advertisement"}</h1>
{page.description && <p className="text-muted-foreground text-lg">{page.description}</p>}
</div>
{page.body && (
<div className="prose prose-sm sm:prose max-w-none dark:prose-invert whitespace-pre-wrap">
{page.body}
</div>
)}
{links.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
{links.map((l, i) => (
<Button
key={i}
variant={i === 0 ? "default" : "outline"}
onClick={() => {
if (!l.link) return;
if (/^https?:\/\//.test(l.link)) window.open(l.link, "_blank", "noopener,noreferrer");
else navigate(l.link);
}}
>
{l.label || l.link}
</Button>
))}
</div>
)}
</div>
);
}
+45 -58
View File
@@ -29,7 +29,6 @@ import { toast } from "sonner";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { Sidebar, SidebarSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Sidebar";
import { Tags } from "lucide-react";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -542,13 +541,12 @@ const CourseDetails = () => {
getMyTier();
getCourse(courseId);
fetchCourseProgress(courseId);
getActiveAdvertisements(["course_details.banner", "course_details.sidebar"]);
getActiveAdvertisements(["course_details.banner"]);
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId]);
const bannerAd = advertisements["course_details.banner"] ?? null;
const sidebarAd = advertisements["course_details.sidebar"] ?? null;
// Resolve badge image once course loads — issue a client stream token for
// private S3 assets so the badge preview works on this page.
@@ -714,65 +712,54 @@ const CourseDetails = () => {
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex flex-col xs:gap-12 lg:gap-12 flex-1 min-w-0">
<div className="space-y-4">
<div className="font-bold text-2xl">About this course</div>
<div className="max-w-3xl space-y-4 text-muted-foreground lg:text-lg">
<p>{course?.description ?? ""}</p>
</div>
<div className="flex flex-col xs:gap-12 lg:gap-12 flex-1 min-w-0">
<div className="space-y-4">
<div className="font-bold text-2xl">About this course</div>
<div className="max-w-3xl space-y-4 text-muted-foreground lg:text-lg">
<p>{course?.description ?? ""}</p>
</div>
{/* Objectives */}
<div className="space-y-4">
{course?.objectives?.length > 0 && (
<div className="space-y-4">
<div className="font-bold text-2xl">What you will learn</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
))}
</ul>
</div>
)}
</div>
{/* Units — while content isn't ready, only Rewards is shown */}
<div className="space-y-4">
{course?.units?.length > 0 && (
<>
<div className="font-bold text-2xl">
{contentNotReady ? "Rewards" : "Course content"}
</div>
<CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
contentNotReady={contentNotReady}
/>
</>
)}
</div>
</div>
{/* Advertisement Sidebar */}
<aside className="hidden lg:block w-72 shrink-0 sticky top-36 h-fit">
{adLoading["course_details.sidebar"] ? (
<SidebarSkeleton />
) : (
<Sidebar ad={sidebarAd} onCtaClick={handleAdCtaClick} />
{/* Objectives */}
<div className="space-y-4">
{course?.objectives?.length > 0 && (
<div className="space-y-4">
<div className="font-bold text-2xl">What you will learn</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
))}
</ul>
</div>
)}
</aside>
</div>
{/* Units — while content isn't ready, only Rewards is shown */}
<div className="space-y-4">
{course?.units?.length > 0 && (
<>
<div className="font-bold text-2xl">
{contentNotReady ? "Rewards" : "Course content"}
</div>
<CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
contentNotReady={contentNotReady}
/>
</>
)}
</div>
</div>
</div>
</div>
-13
View File
@@ -18,8 +18,6 @@ import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { Building2 } from "lucide-react";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { GitBranch } from "lucide-react";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -166,7 +164,6 @@ const CoursesList = () => {
const navigate = useNavigate();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { fmtCurrency } = useDateFormat();
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
@@ -187,12 +184,9 @@ const CoursesList = () => {
api.get("/client/courses/categories")
.then(({ data }) => setAllCategories(data.data ?? []))
.catch(() => { });
getActiveAdvertisement("course_list.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const bannerAd = advertisements["course_list.banner"] ?? null;
// slug → category info map
const tierMap = useMemo(() => {
const m = {};
@@ -306,13 +300,6 @@ const CoursesList = () => {
</div>
</div>
{/* Advertisement Banner */}
{adLoading["course_list.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
{categoryFilter !== "All" && (
<div className="flex items-center flex-wrap gap-2">
<span className="text-sm text-muted-foreground">Tags:</span>
+4 -19
View File
@@ -26,7 +26,6 @@ import { cn } from "@/lib/utils";
import { useGroup } from "@/contexts/ClientGroupContext";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Hero, HeroSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Hero";
import { Popup } from "@/components/generic/Blocks/Client/Advertisements/Popup";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -214,9 +213,8 @@ const Client = () => {
const { myTier, getMyTier, tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const {
advertisements, getActiveAdvertisements,
adLists, listLoading, getActiveAdvertisementList,
handleAdCtaClick, dismissPopupForever,
handleAdCtaClick,
} = useClientAdvertisements();
const [modalOpen, setModalOpen] = useState(false);
@@ -228,12 +226,9 @@ const Client = () => {
const [lessonModalOpen, setLessonModalOpen] = useState(false);
const [selectedLesson, setSelectedLesson] = useState(null);
const [popupOpen, setPopupOpen] = useState(false);
const userTier = myTier?.tier ?? "free";
const heroAds = adLists["dashboard.hero"] ?? [];
const popupAd = advertisements["dashboard.popup"] ?? null;
// Show welcome toast on first registration
useEffect(() => {
@@ -256,11 +251,8 @@ const Client = () => {
fetchGroups();
}, [])
// ── Resolve active popup ad + hero ad carousel once on mount ─────────────
// ── Resolve hero ad carousel once on mount ────────────────────────────────
useEffect(() => {
getActiveAdvertisements(["dashboard.popup"]).then((result) => {
if (result["dashboard.popup"]) setPopupOpen(true);
});
getActiveAdvertisementList("dashboard.hero");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -387,6 +379,7 @@ const Client = () => {
<UnitCard
key={unit.unit_id}
unit={unit}
tierMap={tierMap}
onViewDetails={handleViewUnitDetails}
/>
))}
@@ -415,6 +408,7 @@ const Client = () => {
<LessonCard
key={lesson.lesson_id}
lesson={lesson}
tierMap={tierMap}
onViewDetails={handleViewLessonDetails}
/>
))}
@@ -425,15 +419,6 @@ const Client = () => {
</div>
</div>
{/* ── Popup Advertisement ── */}
<Popup
ad={popupAd}
open={popupOpen}
onOpenChange={setPopupOpen}
onCtaClick={handleAdCtaClick}
onDismissForever={dismissPopupForever}
/>
{/* ── Upsell Modal — only for locked courses ── */}
<ResponsiveModal
open={modalOpen}
+1
View File
@@ -179,6 +179,7 @@ const LessonsList = () => {
<LessonCard
key={lesson.lesson_id}
lesson={lesson}
tierMap={tierMap}
onViewDetails={handleViewDetails}
/>
))}
+3 -3
View File
@@ -356,11 +356,11 @@ export default function PlanList() {
getPlans();
getMyTier();
getTierCategories();
getActiveAdvertisement("plans.banner");
getActiveAdvertisement("tier_plans.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getPlans, getMyTier]);
const bannerAd = advertisements["plans.banner"] ?? null;
const bannerAd = advertisements["tier_plans.banner"] ?? null;
useEffect(() => {
clearInterval(refundTimerRef.current);
@@ -406,7 +406,7 @@ export default function PlanList() {
<div className="lg:container lg:mx-auto space-y-8 p-6">
{/* Advertisement Banner */}
{adLoading["plans.banner"] ? (
{adLoading["tier_plans.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
+1
View File
@@ -179,6 +179,7 @@ const UnitsList = () => {
<UnitCard
key={unit.unit_id}
unit={unit}
tierMap={tierMap}
onViewDetails={handleViewDetails}
/>
))}
@@ -25,6 +25,7 @@ import MyCertificates from '../pages/MyCertificates'
import MyAchievements from '../pages/MyAchievements'
import AccountSettings from '../pages/AccountSettings'
import Notifications from '../pages/Notifications'
import AdvertisementLandingPage from '../pages/AdvertisementLandingPage'
import IntroPage from '@/modules/auth/pages/Intro'
import { useAuth } from '@/contexts/AuthContext'
@@ -66,6 +67,7 @@ export const ClientRoutes = {
{ path: 'achievements', element: <MyAchievements /> },
{ path: 'settings', element: <AccountSettings /> },
{ path: 'notifications', element: <Notifications /> },
{ path: 'ads/:uuid', element: <AdvertisementLandingPage /> },
{
path: 'plans', element: <Outlet />,
children: [