diff --git a/src/components/generic/Blocks/Admin/VideoBlock.jsx b/src/components/generic/Blocks/Admin/VideoBlock.jsx
index b8e3a43..8c711ca 100644
--- a/src/components/generic/Blocks/Admin/VideoBlock.jsx
+++ b/src/components/generic/Blocks/Admin/VideoBlock.jsx
@@ -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 ? (
{/* ── Video area ── */}
@@ -251,7 +285,19 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
{/* Progress bar */}
-
+
{
+ 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)}
+ >
+
+ {/* Scrub preview — hidden video seeked to hover position, no canvas/sprite needed */}
+ {hoverProgress && (
+
+
+
+
+ {fmtTime(hoverProgress.time)}
+
+
+ )}
{/* Button row */}
diff --git a/src/modules/admin/components/tiers/AccessRuleItemPicker.jsx b/src/modules/admin/components/tiers/AccessRuleItemPicker.jsx
new file mode 100644
index 0000000..64afec3
--- /dev/null
+++ b/src/modules/admin/components/tiers/AccessRuleItemPicker.jsx
@@ -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
;
+ }
+
+ if (total === 0) {
+ return (
+
+
+ No {config.nounPlural} found at this subscription level.
+
+ );
+ }
+
+ const atCap = selectedCount >= MAX_ITEMS;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {filtered.length === 0 ? (
+ No {config.nounPlural} match your search.
+ ) : (
+
+ {filtered.map((item) => {
+ const id = String(item[config.idField]);
+ const checked = selectedIds.includes(id);
+ const disabled = !checked && atCap;
+ return (
+ toggle(id)}
+ disabled={disabled}
+ className={cn("flex items-center gap-3 px-3 py-2.5 cursor-pointer", disabled && "opacity-50")}
+ >
+ toggle(id)}
+ className="shrink-0"
+ onClick={(e) => e.stopPropagation()}
+ />
+ {item.title}
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ {atCap ? `Maximum of ${MAX_ITEMS} reached.` : `Choose up to ${MAX_ITEMS} ${config.nounPlural}.`}
+
+
+ );
+}
diff --git a/src/modules/admin/components/tiers/BundlesCell.jsx b/src/modules/admin/components/tiers/BundlesCell.jsx
new file mode 100644
index 0000000..0089dec
--- /dev/null
+++ b/src/modules/admin/components/tiers/BundlesCell.jsx
@@ -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 (
+
+
+
+ {count} {count === 1 ? singular : plural}
+
+
+ );
+}
+
+function BundleSection({ icon: Icon, title, loading, items, emptyLabel, renderItem, keyField }) {
+ return (
+
+
+ {title} ({items.length})
+
+ {loading ? (
+
+ {[...Array(2)].map((_, i) => )}
+
+ ) : items.length === 0 ? (
+
+
+ {emptyLabel}
+
+ ) : (
+
+ {items.map((item) => (
+
+
+
+
+ {renderItem(item)}
+
+ ))}
+
+ )}
+
+ );
+}
+
+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 (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/modules/admin/components/tiers/TierPlansTable.jsx b/src/modules/admin/components/tiers/TierPlansTable.jsx
index 534afc6..692e078 100644
--- a/src/modules/admin/components/tiers/TierPlansTable.jsx
+++ b/src/modules/admin/components/tiers/TierPlansTable.jsx
@@ -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 */}
diff --git a/src/modules/admin/config/assets/archive/columns.config.jsx b/src/modules/admin/config/assets/archive/columns.config.jsx
index eb49137..ffe9b85 100644
--- a/src/modules/admin/config/assets/archive/columns.config.jsx
+++ b/src/modules/admin/config/assets/archive/columns.config.jsx
@@ -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 = {
//
// );
// },
+ file_size: (info) => (
+
+ {formatFileSize(info.getValue()) ?? "-"}
+
+ ),
};
/**
diff --git a/src/modules/admin/config/assets/columns.config.jsx b/src/modules/admin/config/assets/columns.config.jsx
index eb49137..ffe9b85 100644
--- a/src/modules/admin/config/assets/columns.config.jsx
+++ b/src/modules/admin/config/assets/columns.config.jsx
@@ -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 = {
//
// );
// },
+ file_size: (info) => (
+
+ {formatFileSize(info.getValue()) ?? "-"}
+
+ ),
};
/**
diff --git a/src/modules/admin/config/tiers/plans/archive/columns.config.jsx b/src/modules/admin/config/tiers/plans/archive/columns.config.jsx
index 13c63a8..5c90b95 100644
--- a/src/modules/admin/config/tiers/plans/archive/columns.config.jsx
+++ b/src/modules/admin/config/tiers/plans/archive/columns.config.jsx
@@ -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 (
-
-
-
- {count} {count === 1 ? "unit" : "units"}
-
-
- );
- },
- lessonCount: (info) => {
- const count = parseInt(info.getValue() ?? 0, 10);
- return (
-
-
-
- {count} {count === 1 ? "lesson" : "lessons"}
-
-
- );
- },
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 }) =>
,
+ });
+
return [
buildSelectionColumn(),
- ...buildColumns(visibleAttributes, { cellOverrides }),
+ ...dataColumns,
buildRowActionsColumn(rowActions, { dropdownLabel: "Plan Actions" }),
];
}
diff --git a/src/modules/admin/config/tiers/plans/columns.config.jsx b/src/modules/admin/config/tiers/plans/columns.config.jsx
index 89b3ec9..8f8fb0e 100644
--- a/src/modules/admin/config/tiers/plans/columns.config.jsx
+++ b/src/modules/admin/config/tiers/plans/columns.config.jsx
@@ -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 (
-
-
-
- {count} {count === 1 ? "unit" : "units"}
-
-
- );
- },
- lessonCount: (info) => {
- const count = parseInt(info.getValue() ?? 0, 10);
- return (
-
-
-
- {count} {count === 1 ? "lesson" : "lessons"}
-
-
- );
- },
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 }) =>
,
+ });
return [
buildSelectionColumn(),
- ...buildColumns(visibleAttributes, { cellOverrides }),
+ ...dataColumns,
buildRowActionsColumn(rowActions, { dropdownLabel: "Course Actions" }),
];
}
\ No newline at end of file
diff --git a/src/modules/admin/pages/assets/ViewVideoAsset.jsx b/src/modules/admin/pages/assets/ViewVideoAsset.jsx
index 77a2ef8..f591570 100644
--- a/src/modules/admin/pages/assets/ViewVideoAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewVideoAsset.jsx
@@ -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() {
-
+
diff --git a/src/modules/admin/pages/tiers/AddPlan.jsx b/src/modules/admin/pages/tiers/AddPlan.jsx
index 5b04bab..757f244 100644
--- a/src/modules/admin/pages/tiers/AddPlan.jsx
+++ b/src/modules/admin/pages/tiers/AddPlan.jsx
@@ -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
diff --git a/src/modules/admin/pages/tiers/EditPlan.jsx b/src/modules/admin/pages/tiers/EditPlan.jsx
index e1ea9a1..3822cf3 100644
--- a/src/modules/admin/pages/tiers/EditPlan.jsx
+++ b/src/modules/admin/pages/tiers/EditPlan.jsx
@@ -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],
diff --git a/src/modules/admin/pages/tiers/EditTierCategory.jsx b/src/modules/admin/pages/tiers/EditTierCategory.jsx
index 44e557a..34203d8 100644
--- a/src/modules/admin/pages/tiers/EditTierCategory.jsx
+++ b/src/modules/admin/pages/tiers/EditTierCategory.jsx
@@ -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 }) {
)}
+
+
+ Marks this category as intended for restrictive/limited-access plans (e.g. capped starter-content access rules).
+
{/* Badge */}
diff --git a/src/modules/admin/pages/tiers/ViewPlan.jsx b/src/modules/admin/pages/tiers/ViewPlan.jsx
index 6e2dbaa..86a0052 100644
--- a/src/modules/admin/pages/tiers/ViewPlan.jsx
+++ b/src/modules/admin/pages/tiers/ViewPlan.jsx
@@ -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 }) {
+ Mark this plan's tier category as "Special" (Admin > Tier Categories) to unlock the "Specific item preview" rule type.
+
+ )}
{RULE_TYPES.find((t) => t.value === newType)?.description}
@@ -706,7 +744,7 @@ function AccessRulesTab({ planId }) {