mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
updated some of things
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user