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