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 ( + <> +
+ + + + +
+ + + + + Bundled Content — {plan.label} + + Everything this plan unlocks for subscribers. + + + + +
+ ( +
+

{course.title}

+ {(course.course_code || course.level) && ( +
+ {course.course_code && ( + {course.course_code} + )} + {course.level && ( + {course.level} + )} +
+ )} +
+ )} + /> + + ( +

{unit.title}

+ )} + /> + + ( +

{lesson.title}

+ )} + /> +
+
+
+
+ + ); +} 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 }) { + {!isSpecialCategory && ( +

+ 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 }) {
- {tierCategories.map((c) => ( + {payableTierCategories.map((c) => ( - {tierCategories.map((c) => ( + {payableTierCategories.map((c) => ( {c.name} ))} @@ -761,6 +799,52 @@ function AccessRulesTab({ planId }) {
)} + {newType === "item_allowlist" && ( +
+
+ + +
+ +
+ + +
+ + {newItemSlug && ( +
+ + +
+ )} +
+ )} +
+ ) : course?.purchase_eligible === false ? ( +
+ +

+ Complete your plan's starter content to unlock this purchase. +

+
) : ( + ) : lesson?.purchase_eligible === false ? ( +
+ +

+ Complete your plan's starter content to unlock this purchase. +

+
) : ( + ) : unit?.purchase_eligible === false ? ( +
+ +

+ Complete your plan's starter content to unlock this purchase. +

+
) : (