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
|
// blank black box with no indication anything was happening, especially
|
||||||
// on large/slow-loading files.
|
// on large/slow-loading files.
|
||||||
const [mediaLoading, setMediaLoading] = useState(true);
|
const [mediaLoading, setMediaLoading] = useState(true);
|
||||||
|
const [hoverProgress, setHoverProgress] = useState(null); // { x, time }
|
||||||
|
const previewVidRef = useRef(null);
|
||||||
|
|
||||||
// Reset player when video changes
|
// Reset player when video changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -138,6 +140,36 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
else el.requestFullscreen?.();
|
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
|
// #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
|
// below it (previously just the video), so fullscreen no longer drops the
|
||||||
// seek bar / volume / fullscreen button. isFullscreen also relaxes the
|
// seek bar / volume / fullscreen button. isFullscreen also relaxes the
|
||||||
@@ -189,7 +221,9 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
) : src ? (
|
) : src ? (
|
||||||
<div
|
<div
|
||||||
id="video-block-wrap"
|
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 ── */}
|
{/* ── 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">
|
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-2">
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* 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
|
<div
|
||||||
className="h-full bg-foreground rounded-full transition-[width] duration-100"
|
className="h-full bg-foreground rounded-full transition-[width] duration-100"
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
@@ -263,6 +309,31 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
aria-label="Seek"
|
aria-label="Seek"
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Button row */}
|
{/* 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)}
|
onArchive={(entity) => deletePlan(entity?.plan_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleSuccess}
|
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 */}
|
{/* Bulk archive */}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionC
|
|||||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
|
|
||||||
export const columnPinning = {
|
export const columnPinning = {
|
||||||
right: ["actions"],
|
right: ["actions"],
|
||||||
@@ -25,6 +26,11 @@ const cellOverrides = {
|
|||||||
// </div>
|
// </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 { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
|
|
||||||
export const columnPinning = {
|
export const columnPinning = {
|
||||||
right: ["actions"],
|
right: ["actions"],
|
||||||
@@ -25,6 +26,11 @@ const cellOverrides = {
|
|||||||
// </div>
|
// </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 { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { formatDuration } from "@/utils/timestamp.util";
|
||||||
|
import BundlesCell from "@/modules/admin/components/tiers/BundlesCell";
|
||||||
|
|
||||||
export const columnPinning = {
|
export const columnPinning = {
|
||||||
right: ["actions"],
|
right: ["actions"],
|
||||||
@@ -11,28 +12,6 @@ export const columnPinning = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cellOverrides = {
|
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) => {
|
duration_seconds: (info) => {
|
||||||
const seconds = parseInt(info.getValue() ?? 0, 10);
|
const seconds = parseInt(info.getValue() ?? 0, 10);
|
||||||
return (
|
return (
|
||||||
@@ -46,11 +25,29 @@ const cellOverrides = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EXCLUDED_FIELDS = ["duration_unit", "courseCount", "unitCount", "lessonCount", "bundleCount"];
|
||||||
|
|
||||||
export function buildDataColumns(attributes, rowActions) {
|
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 [
|
return [
|
||||||
buildSelectionColumn(),
|
buildSelectionColumn(),
|
||||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
...dataColumns,
|
||||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Plan Actions" }),
|
buildRowActionsColumn(rowActions, { dropdownLabel: "Plan Actions" }),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { buildColumns } from "@/utils/table.util";
|
|||||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { formatDuration } from "@/utils/timestamp.util";
|
||||||
|
import BundlesCell from "@/modules/admin/components/tiers/BundlesCell";
|
||||||
|
|
||||||
export const columnPinning = {
|
export const columnPinning = {
|
||||||
right: ["actions"],
|
right: ["actions"],
|
||||||
@@ -28,28 +29,6 @@ function fmtPlanDuration(days, unit) {
|
|||||||
|
|
||||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||||
const cellOverrides = {
|
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) => {
|
duration_days: (info) => {
|
||||||
const days = info.getValue();
|
const days = info.getValue();
|
||||||
const unit = info.row.original.duration_unit;
|
const unit = info.row.original.duration_unit;
|
||||||
@@ -82,12 +61,29 @@ const cellOverrides = {
|
|||||||
* @param {Array} rowActions Row-level kebab action definitions
|
* @param {Array} rowActions Row-level kebab action definitions
|
||||||
* @returns {Array} TanStack column definitions
|
* @returns {Array} TanStack column definitions
|
||||||
*/
|
*/
|
||||||
|
const EXCLUDED_FIELDS = ["duration_unit", "courseCount", "unitCount", "lessonCount", "bundleCount"];
|
||||||
|
|
||||||
export function buildDataColumns(attributes, rowActions) {
|
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 [
|
return [
|
||||||
buildSelectionColumn(),
|
buildSelectionColumn(),
|
||||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
...dataColumns,
|
||||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Course Actions" }),
|
buildRowActionsColumn(rowActions, { dropdownLabel: "Course Actions" }),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import { ArrowLeft, Lock, Globe } from "lucide-react";
|
|||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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() {
|
export default function ViewVideoAsset() {
|
||||||
const { assetId } = useParams();
|
const { assetId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -111,7 +101,7 @@ export default function ViewVideoAsset() {
|
|||||||
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
|
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
|
||||||
<MetaRow label="Resolution" value={a.resolution} />
|
<MetaRow label="Resolution" value={a.resolution} />
|
||||||
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
<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="Frame Rate" value={a.frame_rate ? `${a.frame_rate} fps` : null} />
|
||||||
<MetaRow label="Bitrate" value={a.bitrate ? `${a.bitrate} kbps` : null} />
|
<MetaRow label="Bitrate" value={a.bitrate ? `${a.bitrate} kbps` : null} />
|
||||||
<MetaRow label="Video Codec" value={a.video_codec} />
|
<MetaRow label="Video Codec" value={a.video_codec} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowLeft, ArrowRight, Check, House, Plus, Trash2 } from "lucide-react";
|
import { ArrowLeft, ArrowRight, Check, House, Plus, Trash2 } from "lucide-react";
|
||||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -138,6 +139,7 @@ function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
|
|||||||
export default function AddPlan() {
|
export default function AddPlan() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createPlan, loading } = useTiers();
|
const { createPlan, loading } = useTiers();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [maxStepReached, setMaxStepReached] = useState(0);
|
const [maxStepReached, setMaxStepReached] = useState(0);
|
||||||
@@ -228,7 +230,7 @@ export default function AddPlan() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (values) => {
|
const onSubmit = async (values) => {
|
||||||
const result = await createPlan(values);
|
const result = await createPlan({ ...values, createdBy: user?.user_id });
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
// Sync selected bundles
|
// Sync selected bundles
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowLeft, House, TriangleAlert, Plus, Trash2 } from "lucide-react";
|
import { ArrowLeft, House, TriangleAlert, Plus, Trash2 } from "lucide-react";
|
||||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -85,6 +86,7 @@ export default function EditPlan() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { planId } = useParams();
|
const { planId } = useParams();
|
||||||
const { fetchPlan, plan, updatePlan, loading } = useTiers();
|
const { fetchPlan, plan, updatePlan, loading } = useTiers();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||||
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
||||||
@@ -192,7 +194,7 @@ export default function EditPlan() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const doSave = async (values) => {
|
const doSave = async (values) => {
|
||||||
const result = await updatePlan(planId, values);
|
const result = await updatePlan(planId, { ...values, updatedBy: user?.user_id });
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
await api.post(`/admin/tiers/${planId}/courses`, {
|
await api.post(`/admin/tiers/${planId}/courses`, {
|
||||||
course_ids: [...selectedCourseIds],
|
course_ids: [...selectedCourseIds],
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ function EditTierCategoryInner({ isAdd }) {
|
|||||||
const [badgeIcon, setBadgeIcon] = useState(null);
|
const [badgeIcon, setBadgeIcon] = useState(null);
|
||||||
const [badgeLabel, setBadgeLabel] = useState("");
|
const [badgeLabel, setBadgeLabel] = useState("");
|
||||||
const [isActive, setIsActive] = useState(true);
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [isSpecial, setIsSpecial] = useState(false);
|
||||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||||
const [clearBadge, setClearBadge] = useState(false);
|
const [clearBadge, setClearBadge] = useState(false);
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
@@ -168,6 +169,7 @@ function EditTierCategoryInner({ isAdd }) {
|
|||||||
setBadgeIcon(category.badge_icon ?? null);
|
setBadgeIcon(category.badge_icon ?? null);
|
||||||
setBadgeLabel(category.badge_label ?? "");
|
setBadgeLabel(category.badge_label ?? "");
|
||||||
setIsActive(category.is_active ?? true);
|
setIsActive(category.is_active ?? true);
|
||||||
|
setIsSpecial(category.is_special ?? false);
|
||||||
setSelectedAsset(null);
|
setSelectedAsset(null);
|
||||||
setClearBadge(false);
|
setClearBadge(false);
|
||||||
}
|
}
|
||||||
@@ -193,6 +195,7 @@ function EditTierCategoryInner({ isAdd }) {
|
|||||||
badge_icon: badgeIcon || null,
|
badge_icon: badgeIcon || null,
|
||||||
badge_label: badgeLabel.trim() || null,
|
badge_label: badgeLabel.trim() || null,
|
||||||
is_active: isActive,
|
is_active: isActive,
|
||||||
|
is_special: isSpecial,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (selectedAsset) payload.badge_asset_id = selectedAsset.asset_id;
|
if (selectedAsset) payload.badge_asset_id = selectedAsset.asset_id;
|
||||||
@@ -308,6 +311,14 @@ function EditTierCategoryInner({ isAdd }) {
|
|||||||
<Label htmlFor="is_active">Active</Label>
|
<Label htmlFor="is_active">Active</Label>
|
||||||
</div>
|
</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>
|
</SectionCard>
|
||||||
|
|
||||||
{/* Badge */}
|
{/* Badge */}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
|
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
|
||||||
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock, FileText,
|
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock, FileText,
|
||||||
|
ListChecks,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -19,6 +20,7 @@ import { useDateFormat } from "@/hooks/useDateFormat";
|
|||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
import { AccessRuleItemPicker } from "@/modules/admin/components/tiers/AccessRuleItemPicker";
|
||||||
import PaymentsTable from "@/modules/admin/components/tiers/PaymentsTable";
|
import PaymentsTable from "@/modules/admin/components/tiers/PaymentsTable";
|
||||||
|
|
||||||
// ─── Shared helpers ────────────────────────────────────────────────────────────
|
// ─── Shared helpers ────────────────────────────────────────────────────────────
|
||||||
@@ -555,8 +557,12 @@ const RULE_TYPES = [
|
|||||||
description: "User's active tier must be at least this rank." },
|
description: "User's active tier must be at least this rank." },
|
||||||
{ value: "group_restriction", label: "Group restriction", icon: Users,
|
{ value: "group_restriction", label: "Group restriction", icon: Users,
|
||||||
description: "User must belong to at least one of these groups." },
|
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) {
|
function ruleSummary(rule, tierCategories, groups) {
|
||||||
if (rule.type === "course_subscription_access") {
|
if (rule.type === "course_subscription_access") {
|
||||||
const names = (rule.levels ?? []).map((slug) => tierCategories.find((c) => c.slug === slug)?.name ?? slug);
|
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);
|
const names = (rule.group_ids ?? []).map((id) => groups.find((g) => String(g.group_id) === String(id))?.name ?? id);
|
||||||
return `Restricted to groups: ${names.join(", ") || "—"}`;
|
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;
|
return rule.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccessRulesTab({ planId }) {
|
function AccessRulesTab({ planId, tierCategoryId }) {
|
||||||
const [rules, setRules] = useState([]);
|
const [rules, setRules] = useState([]);
|
||||||
const [rulesLoading, setRulesLoading] = useState(true);
|
const [rulesLoading, setRulesLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [tierCategories, setTierCategories] = useState([]);
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
const [groups, setGroups] = 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 [showAdd, setShowAdd] = useState(false);
|
||||||
const [newType, setNewType] = useState("course_subscription_access");
|
const [newType, setNewType] = useState("course_subscription_access");
|
||||||
const [newLevels, setNewLevels] = useState([]);
|
const [newLevels, setNewLevels] = useState([]);
|
||||||
const [newTier, setNewTier] = useState("");
|
const [newTier, setNewTier] = useState("");
|
||||||
const [newGroupIds, setNewGroupIds] = useState([]);
|
const [newGroupIds, setNewGroupIds] = useState([]);
|
||||||
|
const [newItemType, setNewItemType] = useState("course");
|
||||||
|
const [newItemSlug, setNewItemSlug] = useState("");
|
||||||
|
const [newItemIds, setNewItemIds] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRulesLoading(true);
|
setRulesLoading(true);
|
||||||
@@ -616,6 +643,9 @@ function AccessRulesTab({ planId }) {
|
|||||||
setNewLevels([]);
|
setNewLevels([]);
|
||||||
setNewTier("");
|
setNewTier("");
|
||||||
setNewGroupIds([]);
|
setNewGroupIds([]);
|
||||||
|
setNewItemType("course");
|
||||||
|
setNewItemSlug("");
|
||||||
|
setNewItemIds([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddRule = () => {
|
const handleAddRule = () => {
|
||||||
@@ -626,9 +656,12 @@ function AccessRulesTab({ planId }) {
|
|||||||
} else if (newType === "required_active_tier") {
|
} else if (newType === "required_active_tier") {
|
||||||
if (!newTier) { toast("Select a required tier."); return; }
|
if (!newTier) { toast("Select a required tier."); return; }
|
||||||
rule = { type: newType, tier: newTier };
|
rule = { type: newType, tier: newTier };
|
||||||
} else {
|
} else if (newType === "group_restriction") {
|
||||||
if (!newGroupIds.length) { toast("Select at least one group."); return; }
|
if (!newGroupIds.length) { toast("Select at least one group."); return; }
|
||||||
rule = { type: newType, group_ids: newGroupIds.map(Number) };
|
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]);
|
handleSave([...rules, rule]);
|
||||||
resetAddForm();
|
resetAddForm();
|
||||||
@@ -692,11 +725,16 @@ function AccessRulesTab({ planId }) {
|
|||||||
<Select value={newType} onValueChange={setNewType}>
|
<Select value={newType} onValueChange={setNewType}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{RULE_TYPES.map((t) => (
|
{availableRuleTypes.map((t) => (
|
||||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{RULE_TYPES.find((t) => t.value === newType)?.description}
|
{RULE_TYPES.find((t) => t.value === newType)?.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -706,7 +744,7 @@ function AccessRulesTab({ planId }) {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Allowed Levels</Label>
|
<Label>Allowed Levels</Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tierCategories.map((c) => (
|
{payableTierCategories.map((c) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={c.slug}
|
key={c.slug}
|
||||||
variant={newLevels.includes(c.slug) ? "default" : "outline"}
|
variant={newLevels.includes(c.slug) ? "default" : "outline"}
|
||||||
@@ -728,7 +766,7 @@ function AccessRulesTab({ planId }) {
|
|||||||
<Select value={newTier} onValueChange={setNewTier}>
|
<Select value={newTier} onValueChange={setNewTier}>
|
||||||
<SelectTrigger><SelectValue placeholder="Select a tier" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Select a tier" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tierCategories.map((c) => (
|
{payableTierCategories.map((c) => (
|
||||||
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -761,6 +799,52 @@ function AccessRulesTab({ planId }) {
|
|||||||
</div>
|
</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">
|
<div className="flex gap-2 justify-end pt-1">
|
||||||
<Button variant="outline" size="sm" onClick={resetAddForm}>Cancel</Button>
|
<Button variant="outline" size="sm" onClick={resetAddForm}>Cancel</Button>
|
||||||
<Button size="sm" onClick={handleAddRule} disabled={saving}>
|
<Button size="sm" onClick={handleAddRule} disabled={saving}>
|
||||||
@@ -918,7 +1002,7 @@ export default function ViewPlan() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "access" && (
|
{activeTab === "access" && (
|
||||||
<AccessRulesTab planId={planId} />
|
<AccessRulesTab planId={planId} tierCategoryId={plan?.tier_category_id} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "policy" && (
|
{activeTab === "policy" && (
|
||||||
<PaymentPolicyTab planId={planId} plan={plan} />
|
<PaymentPolicyTab planId={planId} plan={plan} />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// Shared by LessonsList and Dashboard.
|
// Shared by LessonsList and Dashboard.
|
||||||
|
|
||||||
import { useNavigate } from "react-router-dom";
|
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 ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -18,7 +18,9 @@ export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap
|
|||||||
const { fmtCurrency } = useDateFormat();
|
const { fmtCurrency } = useDateFormat();
|
||||||
const courses = lesson?.courses ?? [];
|
const courses = lesson?.courses ?? [];
|
||||||
const ownTier = lesson?.subscription ? resolveTierBadge(lesson.subscription, tierMap) : null;
|
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 (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
@@ -57,6 +59,15 @@ export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap
|
|||||||
</div>
|
</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 ? (
|
{courses.length === 0 && !ownTier ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Upgrade your plan to access this content.
|
Upgrade your plan to access this content.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// instead of only ever pointing at an attached course.
|
// instead of only ever pointing at an attached course.
|
||||||
|
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
@@ -20,7 +20,9 @@ export default function LockedContentPanel({ course, item, tierMap = {}, checkou
|
|||||||
const { fmtCurrency } = useDateFormat();
|
const { fmtCurrency } = useDateFormat();
|
||||||
const tier = course?.subscription ? tierMap[course.subscription] : null;
|
const tier = course?.subscription ? tierMap[course.subscription] : null;
|
||||||
const ownTier = item?.subscription ? resolveTierBadge(item.subscription, tierMap) : 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 (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
|
<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."}
|
: "Upgrade your plan to access this content."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 flex-col items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{canBuy && (
|
{canBuy && (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// Shared by UnitsList, UnitDetails, and Dashboard.
|
// Shared by UnitsList, UnitDetails, and Dashboard.
|
||||||
|
|
||||||
import { useNavigate } from "react-router-dom";
|
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 ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -18,7 +18,10 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
|
|||||||
const { fmtCurrency } = useDateFormat();
|
const { fmtCurrency } = useDateFormat();
|
||||||
const courses = unit?.courses ?? [];
|
const courses = unit?.courses ?? [];
|
||||||
const ownTier = unit?.subscription ? resolveTierBadge(unit.subscription, tierMap) : null;
|
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 (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
@@ -57,6 +60,15 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
|
|||||||
</div>
|
</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 ? (
|
{courses.length === 0 && !ownTier ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Upgrade your plan to access this content.
|
Upgrade your plan to access this content.
|
||||||
|
|||||||
@@ -205,6 +205,15 @@ export default function CourseCheckout() {
|
|||||||
<Button size="lg" className="w-full" variant="outline" disabled>
|
<Button size="lg" className="w-full" variant="outline" disabled>
|
||||||
Already Purchased
|
Already Purchased
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -148,6 +148,15 @@ export default function LessonCheckout() {
|
|||||||
<Button size="lg" className="w-full" variant="outline" disabled>
|
<Button size="lg" className="w-full" variant="outline" disabled>
|
||||||
Already Purchased
|
Already Purchased
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -148,6 +148,15 @@ export default function UnitCheckout() {
|
|||||||
<Button size="lg" className="w-full" variant="outline" disabled>
|
<Button size="lg" className="w-full" variant="outline" disabled>
|
||||||
Already Purchased
|
Already Purchased
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ function resolveColumns(tableInstance, attributes) {
|
|||||||
(typeof col.columnDef.header === "string" ? col.columnDef.header : col.id),
|
(typeof col.columnDef.header === "string" ? col.columnDef.header : col.id),
|
||||||
width: col.columnDef.meta?.exportWidth ?? 20,
|
width: col.columnDef.meta?.exportWidth ?? 20,
|
||||||
boolLabels: BOOLEAN_FIELD_LABELS[col.id],
|
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 titleRow = [titleCell(title ?? sheetName), ...Array(columns.length - 1).fill("")];
|
||||||
const headerRow = columns.map((c) => headerCell(c.label ?? c.key));
|
const headerRow = columns.map((c) => headerCell(c.label ?? c.key));
|
||||||
const dataRows = data.map((row) =>
|
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 });
|
const footerLines = buildFooterLines({ generatedBy, recordCount: data.length, timezone, footerNote });
|
||||||
|
|||||||
Reference in New Issue
Block a user