updated some of things

This commit is contained in:
rgrgogu
2026-08-03 12:03:11 +08:00
parent 1315796412
commit 6be0c29850
75 changed files with 1743 additions and 1702 deletions
@@ -60,7 +60,6 @@ const schema = z.object({
}).default({}),
start_date: z.string().optional(),
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
}).superRefine((data, ctx) => {
const format = PLACEMENT_MAP[data.placement]?.format;
@@ -84,8 +83,8 @@ const ALL_STEPS = [
{ 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." },
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates and draft/active status." },
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this ad." },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -245,11 +244,21 @@ function StepContent({
<StepImagePicker selectedAsset={selectedAsset} imageUrl={imageUrl} setPickerOpen={setPickerOpen} />
</div>
{contentMode === "image" && (
<div>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Summer Enrollment Banner" {...register("headline")} />
<p className="text-xs text-muted-foreground mt-1.5">
Internal label shown in the Ads list — not displayed on the ad itself.
</p>
</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")} />
<Input placeholder="e.g. Ad" {...register("badge_label")} />
</div>
<div>
<Label className="mb-1.5 block">Headline</Label>
@@ -373,7 +382,7 @@ function StepPageBuilder({ register, linkFields, appendLink, removeLink }) {
// ─── Step 4: Scheduling & Display ───────────────────────────────────────────
function StepScheduling({ register, watch, setValue }) {
function StepScheduling({ watch, setValue }) {
const isActive = watch("is_active");
return (
@@ -397,13 +406,10 @@ function StepScheduling({ register, watch, setValue }) {
</div>
</div>
<Separator />
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Order</Label>
<Input type="number" min={0} {...register("order")} />
</div>
<div>
<Label className="mb-1.5 block">Status</Label>
<div className="flex items-center justify-between border rounded-md px-3 h-9">
<Label className="text-sm">{isActive ? "Active" : "Draft"}</Label>
<span className="text-sm">{isActive ? "Active" : "Draft"}</span>
<Switch
checked={isActive}
onCheckedChange={(v) => setValue("is_active", v)}
@@ -495,7 +501,6 @@ function StepReview({ data, selectedAsset, imageUrl }) {
<p className="text-sm font-medium mb-2">Scheduling & display</p>
<SummaryRow label="Start date" value={data.start_date ? fmtDateTime(data.start_date) : null} />
<SummaryRow label="End date" value={data.end_date ? fmtDateTime(data.end_date) : null} />
<SummaryRow label="Order" value={data.order} />
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
</div>
</div>
@@ -537,7 +542,6 @@ export default function AddAdvertisement() {
landing_page: { title: "", description: "", body: "", links: [] },
start_date: "",
end_date: "",
order: 0,
is_active: true,
},
});
@@ -565,7 +569,7 @@ export default function AddAdvertisement() {
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements", to: "/admin/advertisements" },
{ label: "Ads", to: "/admin/advertisements" },
{ label: "New" },
];
@@ -651,7 +655,7 @@ export default function AddAdvertisement() {
/>
)}
{current.id === "scheduling" && (
<StepScheduling register={register} watch={watch} setValue={setValue} />
<StepScheduling watch={watch} setValue={setValue} />
)}
{current.id === "review" && (
<StepReview data={getValues()} selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} />
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2, Archive } from "lucide-react";
import { House, Plus, Search, Megaphone, ListOrdered, Edit, Trash2, Archive, ArrowUp, ArrowDown } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { resolveAssetSrc } from "@/utils/media.util";
@@ -18,14 +18,14 @@ import {
} from "@/components/ui/alert-dialog";
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_FILTERABLE_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { PLACEMENT_MAP } from "@/data/placement.data";
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
import { TablePagination } from "@/components/generic/Table/TablePagination";
const DEFAULT_PAGE_SIZE = 10;
export default function AdvertisementList() {
const navigate = useNavigate();
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement, reorderAdvertisement } = useAdvertisements();
const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
@@ -57,7 +57,7 @@ export default function AdvertisementList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements" },
{ label: "Ads" },
];
const total = pagination?.totalRecords ?? advertisements.length;
@@ -68,6 +68,25 @@ export default function AdvertisementList() {
await archiveAdvertisement(advertisementId);
}
async function handleReorder(advertisementId, direction) {
await reorderAdvertisement(advertisementId, direction);
}
// Ads grouped by placement (order matters only within a placement — it's
// what the live site uses to pick which ad wins that slot), sorted by
// `order` ASC. Any ad whose placement isn't in the known registry falls
// into a trailing "Unassigned" group instead of disappearing.
const knownPlacementKeys = new Set(PLACEMENTS.map((p) => p.key));
const groups = [
...PLACEMENTS.map((p) => ({ key: p.key, heading: `${p.pageLabel} — ${p.slotLabel}` })),
{ key: null, heading: "Unassigned placement" },
].map(({ key, heading }) => ({
key, heading,
ads: advertisements
.filter((a) => (key ? a.placement === key : !knownPlacementKeys.has(a.placement)))
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
})).filter((g) => g.ads.length > 0);
return (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
@@ -80,7 +99,7 @@ export default function AdvertisementList() {
{/* ── Header ─────────────────────────────────────────────────── */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
<h1 className="text-2xl font-semibold tracking-tight">Ads</h1>
<p className="text-sm text-muted-foreground">Manage public-facing hero and banner placements</p>
</div>
<div className="flex items-center gap-2">
@@ -90,7 +109,7 @@ export default function AdvertisementList() {
</Button>
<Button onClick={() => navigate("/admin/advertisements/add")}>
<Plus className="size-4" />
New advertisement
New ad
</Button>
</div>
</div>
@@ -132,7 +151,7 @@ export default function AdvertisementList() {
<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..."
placeholder="Search ads..."
className="pl-8 bg-background"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
@@ -154,15 +173,27 @@ export default function AdvertisementList() {
<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="flex flex-col gap-6">
{groups.map((group) => (
<div key={group.key ?? "unassigned"} className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-muted-foreground">{group.heading}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{group.ads.map((ad, index) => (
<AdvertisementCard
key={ad.advertisement_id}
ad={ad}
position={index + 1}
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
onArchive={() => handleArchive(ad.advertisement_id)}
canMoveUp={index > 0}
canMoveDown={index < group.ads.length - 1}
onMoveUp={() => handleReorder(ad.advertisement_id, "up")}
onMoveDown={() => handleReorder(ad.advertisement_id, "down")}
/>
))}
</div>
</div>
))}
</div>
@@ -172,7 +203,7 @@ export default function AdvertisementList() {
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
rowCount={advertisements.length}
recordLabel="advertisement"
recordLabel="ad"
/>
</div>
</>
@@ -203,7 +234,7 @@ function StatCard({ label, value, tone = "default" }) {
// ─── Advertisement card ─────────────────────────────────────────────────────
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
function AdvertisementCard({ ad, position, onView, onEdit, onArchive, canMoveUp, canMoveDown, onMoveUp, onMoveDown }) {
const { fmtDateTime } = useDateFormat();
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
@@ -221,7 +252,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
type="button"
onClick={onView}
className="h-32 bg-muted dark:bg-purple-950 relative flex items-center justify-center w-full text-left cursor-pointer"
aria-label="View advertisement details"
aria-label="View ad details"
>
{previewSrc ? (
<img src={previewSrc} alt={ad.headline || ad.type} className="w-full h-full object-cover" />
@@ -240,7 +271,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
<div className="p-3 flex flex-col gap-2 flex-1">
<button type="button" onClick={onView} className="text-left">
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled advertisement"}</p>
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled ad"}</p>
{placementMeta ? (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{placementMeta.pageLabel} — {placementMeta.slotLabel}</p>
) : (
@@ -251,10 +282,16 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
<div className="mt-auto flex items-center justify-between text-xs text-muted-foreground pt-2">
<span className="flex items-center gap-1">
<MousePointerClick className="size-3.5" />
{ad.click_count ?? 0} clicks
<ListOrdered className="size-3.5" />
Order #{position}
</span>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="size-7" onClick={onMoveUp} disabled={!canMoveUp} aria-label="Move up">
<ArrowUp className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7" onClick={onMoveDown} disabled={!canMoveDown} aria-label="Move down">
<ArrowDown className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
<Edit className="size-3.5" />
</Button>
@@ -266,9 +303,9 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive this advertisement?</AlertDialogTitle>
<AlertDialogTitle>Archive this ad?</AlertDialogTitle>
<AlertDialogDescription>
"{ad.headline || ad.badge_label || "This advertisement"}" will be moved to archived advertisements. You can restore it later.
"{ad.headline || ad.badge_label || "This ad"}" will be moved to archived ads. You can restore it later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -291,12 +328,12 @@ function EmptyState({ onCreate }) {
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
<Megaphone className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">No advertisements yet</p>
<p className="font-medium">No ads yet</p>
<p className="text-sm text-muted-foreground">Create your first hero or banner placement.</p>
</div>
<Button onClick={onCreate}>
<Plus className="size-4" />
New advertisement
New ad
</Button>
</div>
);
@@ -6,7 +6,7 @@ import ArchivedAdvertisementsTable from "../../components/advertisements/Archive
export default function ArchivedAdvertisementList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Advertisements", to: `/admin/advertisements` },
{ label: "Ads", to: `/admin/advertisements` },
{ label: "Archived" },
];
@@ -54,7 +54,6 @@ const schema = z.object({
}).default({}),
start_date: z.string().optional(),
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
}).superRefine((data, ctx) => {
const format = PLACEMENT_MAP[data.placement]?.format;
@@ -134,7 +133,6 @@ export default function EditAdvertisement() {
landing_page: { title: "", description: "", body: "", links: [] },
start_date: "",
end_date: "",
order: 0,
is_active: true,
},
});
@@ -152,7 +150,7 @@ export default function EditAdvertisement() {
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements", to: "/admin/advertisements" },
{ label: "Ads", to: "/admin/advertisements" },
{ label: "Edit" },
];
@@ -192,7 +190,6 @@ export default function EditAdvertisement() {
},
start_date: ad.start_date ?? "",
end_date: ad.end_date ?? "",
order: ad.order ?? 0,
is_active: ad.is_active ?? true,
});
@@ -287,11 +284,21 @@ export default function EditAdvertisement() {
))}
</div>
{contentMode === "image" && (
<div>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Summer Enrollment Banner" {...register("headline")} />
<p className="text-xs text-muted-foreground mt-1.5">
Internal label shown in the Ads list — not displayed on the ad itself.
</p>
</div>
)}
{contentMode === "content" && (
<>
<div>
<Label className="mb-1.5 block">Badge label</Label>
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
<Input placeholder="e.g. Ad" {...register("badge_label")} />
</div>
<div>
<Label className="mb-1.5 block">Headline</Label>
@@ -458,14 +465,11 @@ export default function EditAdvertisement() {
</div>
</SectionCard>
<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>
<SectionCard title="Display" description="Draft/active switch.">
<div>
<Label className="mb-1.5 block">Status</Label>
<div className="flex items-center justify-between border rounded-md px-3 h-9">
<Label className="text-sm">{watch("is_active") ? "Active" : "Draft"}</Label>
<span className="text-sm">{watch("is_active") ? "Active" : "Draft"}</span>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
@@ -60,7 +60,7 @@ export default function ViewAdvertisement() {
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements", to: "/admin/advertisements" },
{ label: "Ads", to: "/admin/advertisements" },
{ label: "View" },
];
@@ -81,7 +81,7 @@ export default function ViewAdvertisement() {
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<p className="text-sm text-muted-foreground">Advertisement not found.</p>
<p className="text-sm text-muted-foreground">Ad not found.</p>
</div>
</section>
);
@@ -112,7 +112,7 @@ export default function ViewAdvertisement() {
</Button>
<div>
<h1 className="text-xl font-semibold tracking-tight">
{advertisement.headline || advertisement.badge_label || "Untitled advertisement"}
{advertisement.headline || advertisement.badge_label || "Untitled ad"}
</h1>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<Badge variant="secondary" className="gap-1">