tier plans missing

This commit is contained in:
rgrgogu
2026-08-01 23:02:46 +08:00
parent 510a883974
commit 1315796412
20 changed files with 616 additions and 79 deletions
@@ -46,6 +46,8 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
// blank black box with no indication anything was happening, especially
// on large/slow-loading files.
const [mediaLoading, setMediaLoading] = useState(true);
const [hoverProgress, setHoverProgress] = useState(null); // { x, time }
const previewVidRef = useRef(null);
// Reset player when video changes
useEffect(() => {
@@ -138,6 +140,36 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
else el.requestFullscreen?.();
};
// Ignore keystrokes aimed at the seek/volume range inputs so arrow keys
// there keep their native behavior instead of double-seeking.
const handleKeyDown = useCallback((e) => {
if (e.target.tagName === "INPUT") return;
const v = vidRef.current;
switch (e.key) {
case " ":
e.preventDefault();
togglePlay();
break;
case "ArrowRight":
e.preventDefault();
if (v && v.duration) v.currentTime = Math.min(v.currentTime + 5, v.duration);
break;
case "ArrowLeft":
e.preventDefault();
if (v) v.currentTime = Math.max(v.currentTime - 5, 0);
break;
case "m":
e.preventDefault();
toggleMute();
break;
case "f":
e.preventDefault();
toggleFullscreen();
break;
default: break;
}
}, [togglePlay]);
// #video-block-wrap now wraps both the video area and the controls bar
// below it (previously just the video), so fullscreen no longer drops the
// seek bar / volume / fullscreen button. isFullscreen also relaxes the
@@ -189,7 +221,9 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
) : src ? (
<div
id="video-block-wrap"
className={`overflow-hidden bg-card ${isFullscreen ? "fixed inset-0 flex flex-col" : "rounded-lg border"}`}
tabIndex={0}
onKeyDown={handleKeyDown}
className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 flex flex-col" : "rounded-lg border"}`}
>
{/* ── Video area ── */}
@@ -251,7 +285,19 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-2">
{/* Progress bar */}
<div className="relative h-1 bg-border rounded-full cursor-pointer">
<div
className="relative h-1 bg-border rounded-full cursor-pointer"
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1);
const time = pct * (vidRef.current?.duration ?? 0);
setHoverProgress({ x: e.clientX - rect.left, time });
if (previewVidRef.current && isFinite(time) && time >= 0) {
previewVidRef.current.currentTime = time;
}
}}
onMouseLeave={() => setHoverProgress(null)}
>
<div
className="h-full bg-foreground rounded-full transition-[width] duration-100"
style={{ width: `${progress}%` }}
@@ -263,6 +309,31 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
aria-label="Seek"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
{/* Scrub preview — hidden video seeked to hover position, no canvas/sprite needed */}
{hoverProgress && (
<div
className="absolute bottom-3 flex flex-col items-center pointer-events-none z-30"
style={{ left: `${hoverProgress.x}px`, transform: "translateX(-50%)" }}
>
<div className="rounded-md overflow-hidden border border-white/20 shadow-xl bg-black" style={{ width: 160, height: 90 }}>
<video
ref={previewVidRef}
src={src}
preload="auto"
muted
playsInline
disablePictureInPicture
disableRemotePlayback
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full object-cover"
/>
</div>
<span className="text-white text-xs mt-1 font-medium tabular-nums drop-shadow bg-black/70 px-1.5 py-0.5 rounded">
{fmtTime(hoverProgress.time)}
</span>
</div>
)}
</div>
{/* Button row */}
@@ -0,0 +1,153 @@
// AccessRuleItemPicker — simple multi-select of specific courses/units/lessons
// at a chosen subscription level, for the "item_allowlist" access rule type.
//
// Deliberately NOT CoursePicker/UnitPicker/LessonPicker — those are tightly
// coupled to the Bundles feature's plan-ownership-conflict logic ("this course
// is already bundled into another plan"), which doesn't apply here. This is
// just "pick some items", reusing the same Popover+Command+Checkbox+ScrollArea
// primitives those pickers use.
import { useState, useEffect, useMemo } from "react";
import { ChevronsUpDown, BookOpen } from "lucide-react";
import { toast } from "sonner";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandEmpty, CommandList, CommandItem } from "@/components/ui/command";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
const MAX_ITEMS = 3;
const ITEM_TYPE_CONFIG = {
course: { endpoint: "/admin/courses/by-subscription", idField: "course_id", nounSingular: "course", nounPlural: "courses" },
unit: { endpoint: "/admin/units/by-subscription", idField: "unit_id", nounSingular: "unit", nounPlural: "units" },
lesson: { endpoint: "/admin/lessons/by-subscription", idField: "lesson_id", nounSingular: "lesson", nounPlural: "lessons" },
};
/**
* Props:
* itemType — 'course' | 'unit' | 'lesson'
* subscriptionSlug — tier slug to browse (e.g. "exclusive"). Null = picker hidden.
* selectedIds — string[] of currently-selected item ids
* onChange — (string[]) => void
*/
export function AccessRuleItemPicker({ itemType, subscriptionSlug, selectedIds, onChange }) {
const config = ITEM_TYPE_CONFIG[itemType];
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const [search, setSearch] = useState("");
useEffect(() => {
if (!subscriptionSlug) { setItems([]); return; }
setLoading(true);
setSearch("");
api.get(`${config.endpoint}?slug=${encodeURIComponent(subscriptionSlug)}`)
.then(({ data }) => setItems(data.data ?? []))
.catch(() => setItems([]))
.finally(() => setLoading(false));
}, [itemType, subscriptionSlug]); // eslint-disable-line react-hooks/exhaustive-deps
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return items;
return items.filter((it) => it.title?.toLowerCase().includes(q));
}, [items, search]);
const toggle = (id) => {
const set = new Set(selectedIds);
if (set.has(id)) {
set.delete(id);
} else {
if (set.size >= MAX_ITEMS) {
toast(`You can select at most ${MAX_ITEMS} ${config.nounPlural}.`);
return;
}
set.add(id);
}
onChange([...set]);
};
const total = items.length;
const selectedCount = selectedIds.length;
if (!subscriptionSlug) return null;
if (loading) {
return <Skeleton className="h-8 w-full" />;
}
if (total === 0) {
return (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
<BookOpen className="size-3.5 shrink-0" />
No {config.nounPlural} found at this subscription level.
</div>
);
}
const atCap = selectedCount >= MAX_ITEMS;
return (
<div className="space-y-1.5">
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn("w-full justify-between gap-2", selectedCount === 0 && "text-muted-foreground")}
>
{selectedCount === 0
? `Select ${config.nounPlural}… (max ${MAX_ITEMS})`
: `${selectedCount} of ${MAX_ITEMS} max selected`}
<ChevronsUpDown className="size-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput placeholder={`Search ${config.nounPlural}…`} value={search} onValueChange={setSearch} />
<CommandList>
{filtered.length === 0 ? (
<CommandEmpty>No {config.nounPlural} match your search.</CommandEmpty>
) : (
<ScrollArea className="h-64">
{filtered.map((item) => {
const id = String(item[config.idField]);
const checked = selectedIds.includes(id);
const disabled = !checked && atCap;
return (
<CommandItem
key={id}
value={id}
onSelect={() => toggle(id)}
disabled={disabled}
className={cn("flex items-center gap-3 px-3 py-2.5 cursor-pointer", disabled && "opacity-50")}
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={() => toggle(id)}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
/>
<span className="text-sm font-medium leading-snug line-clamp-1">{item.title}</span>
</CommandItem>
);
})}
</ScrollArea>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
{atCap ? `Maximum of ${MAX_ITEMS} reached.` : `Choose up to ${MAX_ITEMS} ${config.nounPlural}.`}
</p>
</div>
);
}
@@ -0,0 +1,154 @@
// BundlesCell — Tier Plans table cell showing course/unit/lesson totals for a
// plan, with a "View" trigger that lazy-loads and lists everything included.
import { useEffect, useState } from "react";
import { BookOpen, Book, BookOpenCheck, Eye, FileText } from "lucide-react";
import api from "@/utils/api.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
function CountBadge({ icon: Icon, count, singular, plural }) {
return (
<div className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? singular : plural}
</Badge>
</div>
);
}
function BundleSection({ icon: Icon, title, loading, items, emptyLabel, renderItem, keyField }) {
return (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{title} ({items.length})
</p>
{loading ? (
<div className="space-y-2">
{[...Array(2)].map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
</div>
) : items.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
<Icon className="size-3.5 shrink-0" />
{emptyLabel}
</div>
) : (
<div className="divide-y rounded-lg border overflow-hidden">
{items.map((item) => (
<div key={item[keyField]} className="flex items-center gap-3 px-3 py-2 bg-card">
<div className="h-7 w-7 rounded-md bg-muted flex items-center justify-center shrink-0">
<Icon className="size-3.5 text-muted-foreground" />
</div>
{renderItem(item)}
</div>
))}
</div>
)}
</div>
);
}
export default function BundlesCell({ plan }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [courses, setCourses] = useState([]);
const [units, setUnits] = useState([]);
const [lessons, setLessons] = useState([]);
useEffect(() => {
if (!open) return;
setLoading(true);
Promise.all([
api.get(`/admin/tiers/${plan.plan_id}/courses`).then(({ data }) => setCourses(data.data ?? [])).catch(() => setCourses([])),
api.get(`/admin/tiers/${plan.plan_id}/units`).then(({ data }) => setUnits(data.data ?? [])).catch(() => setUnits([])),
api.get(`/admin/tiers/${plan.plan_id}/lessons`).then(({ data }) => setLessons(data.data ?? [])).catch(() => setLessons([])),
]).finally(() => setLoading(false));
}, [open, plan.plan_id]);
const courseCount = parseInt(plan.courseCount ?? 0, 10);
const unitCount = parseInt(plan.unitCount ?? 0, 10);
const lessonCount = parseInt(plan.lessonCount ?? 0, 10);
return (
<>
<div className="flex items-center gap-3">
<CountBadge icon={BookOpen} count={courseCount} singular="course" plural="courses" />
<CountBadge icon={Book} count={unitCount} singular="unit" plural="units" />
<CountBadge icon={BookOpenCheck} count={lessonCount} singular="lesson" plural="lessons" />
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(true)}>
<Eye className="size-3.5" /> View
</Button>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Bundled Content — {plan.label}</DialogTitle>
<DialogDescription>
Everything this plan unlocks for subscribers.
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-96 pr-3">
<div className="space-y-5">
<BundleSection
icon={BookOpen}
title="Courses"
loading={loading}
items={courses}
emptyLabel="No courses assigned to this plan yet."
keyField="course_id"
renderItem={(course) => (
<div className="min-w-0">
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
{(course.course_code || course.level) && (
<div className="flex items-center gap-2 mt-0.5">
{course.course_code && (
<span className="text-xs text-muted-foreground">{course.course_code}</span>
)}
{course.level && (
<Badge variant="outline" className="text-xs capitalize h-4 px-1.5">{course.level}</Badge>
)}
</div>
)}
</div>
)}
/>
<BundleSection
icon={Book}
title="Units"
loading={loading}
items={units}
emptyLabel="No units assigned to this plan yet."
keyField="unit_id"
renderItem={(unit) => (
<p className="text-sm font-medium line-clamp-1">{unit.title}</p>
)}
/>
<BundleSection
icon={FileText}
title="Lessons"
loading={loading}
items={lessons}
emptyLabel="No lessons assigned to this plan yet."
keyField="lesson_id"
renderItem={(lesson) => (
<p className="text-sm font-medium line-clamp-1">{lesson.title}</p>
)}
/>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
</>
);
}
@@ -151,6 +151,10 @@ export default function TierPlansTable() {
onArchive={(entity) => deletePlan(entity?.plan_id)}
loading={loading}
onSuccess={handleSuccess}
onImpactCheck={async () => {
const { data } = await api.get(`/admin/tiers/${archiveTarget?.plan_id}/impact`);
return [{ label: "active subscriber(s) on this plan", count: data.data?.active_subscriber_count ?? 0 }];
}}
/>
{/* Bulk archive */}
@@ -6,6 +6,7 @@ import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionC
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { Badge } from "@/components/ui/badge";
import { Users } from "lucide-react";
import { formatFileSize } from "@/utils/format.util";
export const columnPinning = {
right: ["actions"],
@@ -25,6 +26,11 @@ const cellOverrides = {
// </div>
// );
// },
file_size: (info) => (
<span className="font-mono text-xs font-semibold text-muted-foreground">
{formatFileSize(info.getValue()) ?? "-"}
</span>
),
};
/**
@@ -6,6 +6,7 @@ import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionC
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { Badge } from "@/components/ui/badge";
import { Users } from "lucide-react";
import { formatFileSize } from "@/utils/format.util";
export const columnPinning = {
right: ["actions"],
@@ -25,6 +26,11 @@ const cellOverrides = {
// </div>
// );
// },
file_size: (info) => (
<span className="font-mono text-xs font-semibold text-muted-foreground">
{formatFileSize(info.getValue()) ?? "-"}
</span>
),
};
/**
@@ -2,8 +2,9 @@ import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { Badge } from "@/components/ui/badge";
import { Book, BookOpenCheck, Clock } from "lucide-react";
import { Clock } from "lucide-react";
import { formatDuration } from "@/utils/timestamp.util";
import BundlesCell from "@/modules/admin/components/tiers/BundlesCell";
export const columnPinning = {
right: ["actions"],
@@ -11,28 +12,6 @@ export const columnPinning = {
};
const cellOverrides = {
unitCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<Book className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "unit" : "units"}
</Badge>
</div>
);
},
lessonCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<BookOpenCheck className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "lesson" : "lessons"}
</Badge>
</div>
);
},
duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10);
return (
@@ -46,11 +25,29 @@ const cellOverrides = {
},
};
const EXCLUDED_FIELDS = ["duration_unit", "courseCount", "unitCount", "lessonCount", "bundleCount"];
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
const visibleAttributes = attributes.filter((a) => !a.hidden && !EXCLUDED_FIELDS.includes(a.field));
const dataColumns = buildColumns(visibleAttributes, { cellOverrides });
const labelIndex = dataColumns.findIndex((c) => c.id === "label");
dataColumns.splice(labelIndex + 1, 0, {
id: "bundleCount",
header: "Bundles",
accessorFn: (row) => (row.courseCount ?? 0) + (row.unitCount ?? 0) + (row.lessonCount ?? 0),
enableSorting: true,
enableColumnFilter: false,
meta: {
label: "Bundles",
exportValue: (row) => `${row.courseCount ?? 0} course(s), ${row.unitCount ?? 0} unit(s), ${row.lessonCount ?? 0} lesson(s)`,
},
cell: ({ row }) => <BundlesCell plan={row.original} />,
});
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
...dataColumns,
buildRowActionsColumn(rowActions, { dropdownLabel: "Plan Actions" }),
];
}
@@ -5,8 +5,9 @@ import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { Badge } from "@/components/ui/badge";
import { Book, BookOpenCheck, Clock } from "lucide-react";
import { Clock } from "lucide-react";
import { formatDuration } from "@/utils/timestamp.util";
import BundlesCell from "@/modules/admin/components/tiers/BundlesCell";
export const columnPinning = {
right: ["actions"],
@@ -28,28 +29,6 @@ function fmtPlanDuration(days, unit) {
// ─── Custom cell overrides ────────────────────────────────────────────────────
const cellOverrides = {
unitCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<Book className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "unit" : "units"}
</Badge>
</div>
);
},
lessonCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<BookOpenCheck className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "lesson" : "lessons"}
</Badge>
</div>
);
},
duration_days: (info) => {
const days = info.getValue();
const unit = info.row.original.duration_unit;
@@ -82,12 +61,29 @@ const cellOverrides = {
* @param {Array} rowActions Row-level kebab action definitions
* @returns {Array} TanStack column definitions
*/
const EXCLUDED_FIELDS = ["duration_unit", "courseCount", "unitCount", "lessonCount", "bundleCount"];
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
const visibleAttributes = attributes.filter((a) => !a.hidden && !EXCLUDED_FIELDS.includes(a.field));
const dataColumns = buildColumns(visibleAttributes, { cellOverrides });
const labelIndex = dataColumns.findIndex((c) => c.id === "label");
dataColumns.splice(labelIndex + 1, 0, {
id: "bundleCount",
header: "Bundles",
accessorFn: (row) => (row.courseCount ?? 0) + (row.unitCount ?? 0) + (row.lessonCount ?? 0),
enableSorting: true,
enableColumnFilter: false,
meta: {
label: "Bundles",
exportValue: (row) => `${row.courseCount ?? 0} course(s), ${row.unitCount ?? 0} unit(s), ${row.lessonCount ?? 0} lesson(s)`,
},
cell: ({ row }) => <BundlesCell plan={row.original} />,
});
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
...dataColumns,
buildRowActionsColumn(rowActions, { dropdownLabel: "Course Actions" }),
];
}
@@ -5,7 +5,7 @@ import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { formatFileSize } from "@/utils/format.util";
import { formatFileSize, formatPlayerTime } from "@/utils/format.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -23,16 +23,6 @@ function MetaRow({ label, value }) {
);
}
function formatDuration(seconds) {
if (!seconds && seconds !== 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return [h > 0 ? String(h).padStart(2, "0") : null, String(m).padStart(2, "0"), String(s).padStart(2, "0")]
.filter(Boolean)
.join(":");
}
export default function ViewVideoAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
@@ -111,7 +101,7 @@ export default function ViewVideoAsset() {
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
<MetaRow label="Resolution" value={a.resolution} />
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
<MetaRow label="Duration" value={formatDuration(a.duration)} />
<MetaRow label="Duration" value={a.duration == null ? null : formatPlayerTime(a.duration)} />
<MetaRow label="Frame Rate" value={a.frame_rate ? `${a.frame_rate} fps` : null} />
<MetaRow label="Bitrate" value={a.bitrate ? `${a.bitrate} kbps` : null} />
<MetaRow label="Video Codec" value={a.video_codec} />
+3 -1
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, ArrowRight, Check, House, Plus, Trash2 } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -138,6 +139,7 @@ function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
export default function AddPlan() {
const navigate = useNavigate();
const { createPlan, loading } = useTiers();
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(0);
const [maxStepReached, setMaxStepReached] = useState(0);
@@ -228,7 +230,7 @@ export default function AddPlan() {
};
const onSubmit = async (values) => {
const result = await createPlan(values);
const result = await createPlan({ ...values, createdBy: user?.user_id });
if (!result) return;
// Sync selected bundles
+3 -1
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House, TriangleAlert, Plus, Trash2 } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -85,6 +86,7 @@ export default function EditPlan() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, updatePlan, loading } = useTiers();
const { user } = useAuth();
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [coursesLoaded, setCoursesLoaded] = useState(false);
@@ -192,7 +194,7 @@ export default function EditPlan() {
};
const doSave = async (values) => {
const result = await updatePlan(planId, values);
const result = await updatePlan(planId, { ...values, updatedBy: user?.user_id });
if (!result) return;
await api.post(`/admin/tiers/${planId}/courses`, {
course_ids: [...selectedCourseIds],
@@ -150,6 +150,7 @@ function EditTierCategoryInner({ isAdd }) {
const [badgeIcon, setBadgeIcon] = useState(null);
const [badgeLabel, setBadgeLabel] = useState("");
const [isActive, setIsActive] = useState(true);
const [isSpecial, setIsSpecial] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const [clearBadge, setClearBadge] = useState(false);
const [errors, setErrors] = useState({});
@@ -168,6 +169,7 @@ function EditTierCategoryInner({ isAdd }) {
setBadgeIcon(category.badge_icon ?? null);
setBadgeLabel(category.badge_label ?? "");
setIsActive(category.is_active ?? true);
setIsSpecial(category.is_special ?? false);
setSelectedAsset(null);
setClearBadge(false);
}
@@ -193,6 +195,7 @@ function EditTierCategoryInner({ isAdd }) {
badge_icon: badgeIcon || null,
badge_label: badgeLabel.trim() || null,
is_active: isActive,
is_special: isSpecial,
};
if (selectedAsset) payload.badge_asset_id = selectedAsset.asset_id;
@@ -308,6 +311,14 @@ function EditTierCategoryInner({ isAdd }) {
<Label htmlFor="is_active">Active</Label>
</div>
)}
<div className="flex items-center gap-3">
<Switch id="is_special" checked={isSpecial} onCheckedChange={setIsSpecial} />
<Label htmlFor="is_special">Special</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2">
Marks this category as intended for restrictive/limited-access plans (e.g. capped starter-content access rules).
</p>
</SectionCard>
{/* Badge */}
+90 -6
View File
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock, FileText,
ListChecks,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -19,6 +20,7 @@ import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { AccessRuleItemPicker } from "@/modules/admin/components/tiers/AccessRuleItemPicker";
import PaymentsTable from "@/modules/admin/components/tiers/PaymentsTable";
// ─── Shared helpers ────────────────────────────────────────────────────────────
@@ -555,8 +557,12 @@ const RULE_TYPES = [
description: "User's active tier must be at least this rank." },
{ value: "group_restriction", label: "Group restriction", icon: Users,
description: "User must belong to at least one of these groups." },
{ value: "item_allowlist", label: "Specific item preview", icon: ListChecks,
description: "Grant access to these exact courses/units/lessons regardless of level — e.g. a curated Exclusive preview for Premium subscribers." },
];
const ITEM_TYPE_LABELS = { course: "Courses", unit: "Units", lesson: "Lessons" };
function ruleSummary(rule, tierCategories, groups) {
if (rule.type === "course_subscription_access") {
const names = (rule.levels ?? []).map((slug) => tierCategories.find((c) => c.slug === slug)?.name ?? slug);
@@ -569,20 +575,41 @@ function ruleSummary(rule, tierCategories, groups) {
const names = (rule.group_ids ?? []).map((id) => groups.find((g) => String(g.group_id) === String(id))?.name ?? id);
return `Restricted to groups: ${names.join(", ") || "—"}`;
}
if (rule.type === "item_allowlist") {
const count = (rule.item_ids ?? []).length;
const noun = ITEM_TYPE_LABELS[rule.item_type]?.toLowerCase() ?? "item(s)";
return `Preview access: ${count} specific ${noun}`;
}
return rule.type;
}
function AccessRulesTab({ planId }) {
function AccessRulesTab({ planId, tierCategoryId }) {
const [rules, setRules] = useState([]);
const [rulesLoading, setRulesLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [tierCategories, setTierCategories] = useState([]);
const [groups, setGroups] = useState([]);
const isSpecialCategory = tierCategories.find(
(c) => String(c.tier_category_id) === String(tierCategoryId)
)?.is_special ?? false;
// Free content is always accessible regardless of any rule (evaluateCourseAccess
// short-circuits rank-0 content to allowed), so Free is never a meaningful
// option in any access rule — only paid levels can actually be gated.
const payableTierCategories = tierCategories.filter((c) => !c.is_default);
const availableRuleTypes = RULE_TYPES.filter(
(t) => t.value !== "item_allowlist" || isSpecialCategory
);
const [showAdd, setShowAdd] = useState(false);
const [newType, setNewType] = useState("course_subscription_access");
const [newLevels, setNewLevels] = useState([]);
const [newTier, setNewTier] = useState("");
const [newGroupIds, setNewGroupIds] = useState([]);
const [newItemType, setNewItemType] = useState("course");
const [newItemSlug, setNewItemSlug] = useState("");
const [newItemIds, setNewItemIds] = useState([]);
useEffect(() => {
setRulesLoading(true);
@@ -616,6 +643,9 @@ function AccessRulesTab({ planId }) {
setNewLevels([]);
setNewTier("");
setNewGroupIds([]);
setNewItemType("course");
setNewItemSlug("");
setNewItemIds([]);
};
const handleAddRule = () => {
@@ -626,9 +656,12 @@ function AccessRulesTab({ planId }) {
} else if (newType === "required_active_tier") {
if (!newTier) { toast("Select a required tier."); return; }
rule = { type: newType, tier: newTier };
} else {
} else if (newType === "group_restriction") {
if (!newGroupIds.length) { toast("Select at least one group."); return; }
rule = { type: newType, group_ids: newGroupIds.map(Number) };
} else {
if (!newItemIds.length) { toast("Select at least one item."); return; }
rule = { type: newType, item_type: newItemType, item_ids: newItemIds };
}
handleSave([...rules, rule]);
resetAddForm();
@@ -692,11 +725,16 @@ function AccessRulesTab({ planId }) {
<Select value={newType} onValueChange={setNewType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{RULE_TYPES.map((t) => (
{availableRuleTypes.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
{!isSpecialCategory && (
<p className="text-xs text-muted-foreground">
Mark this plan's tier category as "Special" (Admin &gt; Tier Categories) to unlock the "Specific item preview" rule type.
</p>
)}
<p className="text-xs text-muted-foreground">
{RULE_TYPES.find((t) => t.value === newType)?.description}
</p>
@@ -706,7 +744,7 @@ function AccessRulesTab({ planId }) {
<div className="space-y-1.5">
<Label>Allowed Levels</Label>
<div className="flex flex-wrap gap-2">
{tierCategories.map((c) => (
{payableTierCategories.map((c) => (
<Badge
key={c.slug}
variant={newLevels.includes(c.slug) ? "default" : "outline"}
@@ -728,7 +766,7 @@ function AccessRulesTab({ planId }) {
<Select value={newTier} onValueChange={setNewTier}>
<SelectTrigger><SelectValue placeholder="Select a tier" /></SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
{payableTierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
))}
</SelectContent>
@@ -761,6 +799,52 @@ function AccessRulesTab({ planId }) {
</div>
)}
{newType === "item_allowlist" && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Item Type</Label>
<Select
value={newItemType}
onValueChange={(v) => { setNewItemType(v); setNewItemIds([]); }}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="course">Course</SelectItem>
<SelectItem value="unit">Unit</SelectItem>
<SelectItem value="lesson">Lesson</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Subscription Level to browse</Label>
<Select
value={newItemSlug}
onValueChange={(v) => { setNewItemSlug(v); setNewItemIds([]); }}
>
<SelectTrigger><SelectValue placeholder="Select a level" /></SelectTrigger>
<SelectContent>
{payableTierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newItemSlug && (
<div className="space-y-1.5">
<Label>{ITEM_TYPE_LABELS[newItemType]}</Label>
<AccessRuleItemPicker
itemType={newItemType}
subscriptionSlug={newItemSlug}
selectedIds={newItemIds}
onChange={setNewItemIds}
/>
</div>
)}
</div>
)}
<div className="flex gap-2 justify-end pt-1">
<Button variant="outline" size="sm" onClick={resetAddForm}>Cancel</Button>
<Button size="sm" onClick={handleAddRule} disabled={saving}>
@@ -918,7 +1002,7 @@ export default function ViewPlan() {
/>
)}
{activeTab === "access" && (
<AccessRulesTab planId={planId} />
<AccessRulesTab planId={planId} tierCategoryId={plan?.tier_category_id} />
)}
{activeTab === "policy" && (
<PaymentPolicyTab planId={planId} plan={plan} />
@@ -6,7 +6,7 @@
// Shared by LessonsList and Dashboard.
import { useNavigate } from "react-router-dom";
import { LockIcon, BookOpen, ShoppingCart } from "lucide-react";
import { LockIcon, BookOpen, ShoppingCart, GraduationCap } from "lucide-react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -18,7 +18,9 @@ export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap
const { fmtCurrency } = useDateFormat();
const courses = lesson?.courses ?? [];
const ownTier = lesson?.subscription ? resolveTierBadge(lesson.subscription, tierMap) : null;
const canBuy = lesson?.product?.is_active && !lesson?.has_purchased;
const purchasable = lesson?.product?.is_active && !lesson?.has_purchased;
const awaitingStarterSet = purchasable && lesson?.purchase_eligible === false;
const canBuy = purchasable && !awaitingStarterSet;
return (
<ResponsiveModal
@@ -57,6 +59,15 @@ export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap
</div>
)}
{awaitingStarterSet && (
<div className="flex items-center gap-2.5 p-4 rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40">
<GraduationCap className="size-4 text-amber-700 dark:text-amber-400 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">
Complete your plan's starter content to unlock individual purchases like this one.
</p>
</div>
)}
{courses.length === 0 && !ownTier ? (
<p className="text-sm text-muted-foreground">
Upgrade your plan to access this content.
@@ -10,7 +10,7 @@
// instead of only ever pointing at an attached course.
import { useNavigate } from "react-router-dom";
import { LockIcon, Zap, ShoppingCart } from "lucide-react";
import { LockIcon, Zap, ShoppingCart, GraduationCap } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge } from "@/utils/tierBadge.util";
@@ -20,7 +20,9 @@ export default function LockedContentPanel({ course, item, tierMap = {}, checkou
const { fmtCurrency } = useDateFormat();
const tier = course?.subscription ? tierMap[course.subscription] : null;
const ownTier = item?.subscription ? resolveTierBadge(item.subscription, tierMap) : null;
const canBuy = item?.product?.is_active && !item?.has_purchased && checkoutPath;
const purchasable = item?.product?.is_active && !item?.has_purchased && checkoutPath;
const awaitingStarterSet = purchasable && item?.purchase_eligible === false;
const canBuy = purchasable && !awaitingStarterSet;
return (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
@@ -37,6 +39,14 @@ export default function LockedContentPanel({ course, item, tierMap = {}, checkou
: "Upgrade your plan to access this content."}
</p>
</div>
{awaitingStarterSet && (
<div className="flex items-center gap-2.5 p-3 rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 max-w-sm">
<GraduationCap className="size-4 text-amber-700 dark:text-amber-400 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300 text-left">
Complete your plan's starter content to unlock individual purchases like this one.
</p>
</div>
)}
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
{canBuy && (
@@ -6,7 +6,7 @@
// Shared by UnitsList, UnitDetails, and Dashboard.
import { useNavigate } from "react-router-dom";
import { LockIcon, BookOpen, ShoppingCart } from "lucide-react";
import { LockIcon, BookOpen, ShoppingCart, GraduationCap } from "lucide-react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -18,7 +18,10 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
const { fmtCurrency } = useDateFormat();
const courses = unit?.courses ?? [];
const ownTier = unit?.subscription ? resolveTierBadge(unit.subscription, tierMap) : null;
const canBuy = unit?.product?.is_active && !unit?.has_purchased;
const purchasable = unit?.product?.is_active && !unit?.has_purchased;
// purchase_eligible is undefined for callers that haven't fetched it yet — treat as eligible (no regression).
const awaitingStarterSet = purchasable && unit?.purchase_eligible === false;
const canBuy = purchasable && !awaitingStarterSet;
return (
<ResponsiveModal
@@ -57,6 +60,15 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
</div>
)}
{awaitingStarterSet && (
<div className="flex items-center gap-2.5 p-4 rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40">
<GraduationCap className="size-4 text-amber-700 dark:text-amber-400 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">
Complete your plan's starter content to unlock individual purchases like this one.
</p>
</div>
)}
{courses.length === 0 && !ownTier ? (
<p className="text-sm text-muted-foreground">
Upgrade your plan to access this content.
@@ -205,6 +205,15 @@ export default function CourseCheckout() {
<Button size="lg" className="w-full" variant="outline" disabled>
Already Purchased
</Button>
) : course?.purchase_eligible === false ? (
<div className="space-y-2">
<Button size="lg" className="w-full" variant="outline" disabled>
Not Yet Available
</Button>
<p className="text-xs text-muted-foreground text-center">
Complete your plan's starter content to unlock this purchase.
</p>
</div>
) : (
<Button
size="lg"
@@ -148,6 +148,15 @@ export default function LessonCheckout() {
<Button size="lg" className="w-full" variant="outline" disabled>
Already Purchased
</Button>
) : lesson?.purchase_eligible === false ? (
<div className="space-y-2">
<Button size="lg" className="w-full" variant="outline" disabled>
Not Yet Available
</Button>
<p className="text-xs text-muted-foreground text-center">
Complete your plan's starter content to unlock this purchase.
</p>
</div>
) : (
<Button
size="lg"
@@ -148,6 +148,15 @@ export default function UnitCheckout() {
<Button size="lg" className="w-full" variant="outline" disabled>
Already Purchased
</Button>
) : unit?.purchase_eligible === false ? (
<div className="space-y-2">
<Button size="lg" className="w-full" variant="outline" disabled>
Not Yet Available
</Button>
<p className="text-xs text-muted-foreground text-center">
Complete your plan's starter content to unlock this purchase.
</p>
</div>
) : (
<Button
size="lg"
+2 -1
View File
@@ -140,6 +140,7 @@ function resolveColumns(tableInstance, attributes) {
(typeof col.columnDef.header === "string" ? col.columnDef.header : col.id),
width: col.columnDef.meta?.exportWidth ?? 20,
boolLabels: BOOLEAN_FIELD_LABELS[col.id],
exportValue: col.columnDef.meta?.exportValue,
}));
}
@@ -242,7 +243,7 @@ export function exportToExcel({
const titleRow = [titleCell(title ?? sheetName), ...Array(columns.length - 1).fill("")];
const headerRow = columns.map((c) => headerCell(c.label ?? c.key));
const dataRows = data.map((row) =>
columns.map((c) => resolveValue(row, c.key, c.boolLabels))
columns.map((c) => c.exportValue ? c.exportValue(row) : resolveValue(row, c.key, c.boolLabels))
);
const footerLines = buildFooterLines({ generatedBy, recordCount: data.length, timezone, footerNote });