mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
assets and tier plans revamp
This commit is contained in:
@@ -14,6 +14,8 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||
import { UnitPicker } from "@/modules/admin/components/tiers/UnitPicker";
|
||||
import { LessonPicker } from "@/modules/admin/components/tiers/LessonPicker";
|
||||
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
|
||||
import api from "@/utils/api.util";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
@@ -75,7 +77,7 @@ function SectionCard({ title, children }) {
|
||||
const STEPS = [
|
||||
{ label: "Category & Label", description: "Tier category, label & description" },
|
||||
{ label: "Duration & Pricing", description: "Billing period, price & currency" },
|
||||
{ label: "Assigned Courses", description: "Choose which courses this unlocks" },
|
||||
{ label: "Bundles", description: "Choose which courses, units & lessons this unlocks" },
|
||||
];
|
||||
|
||||
function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
|
||||
@@ -144,6 +146,10 @@ export default function AddPlan() {
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
const [courseConflicts, setCourseConflicts] = useState(0);
|
||||
const [selectedUnitIds, setSelectedUnitIds] = useState(new Set());
|
||||
const [unitConflicts, setUnitConflicts] = useState(0);
|
||||
const [selectedLessonIds, setSelectedLessonIds] = useState(new Set());
|
||||
const [lessonConflicts, setLessonConflicts] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
@@ -176,10 +182,11 @@ export default function AddPlan() {
|
||||
insertFeature(index + 1, lines.slice(1).map((text) => ({ text })));
|
||||
};
|
||||
|
||||
// selectedCourseIds lives outside the form — this is a create page so it
|
||||
// always starts empty, meaning any selection is a genuine unsaved change.
|
||||
// selectedCourseIds/selectedUnitIds/selectedLessonIds live outside the form
|
||||
// — this is a create page so they always start empty, meaning any
|
||||
// selection is a genuine unsaved change.
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
|
||||
isDirty || selectedCourseIds.size > 0
|
||||
isDirty || selectedCourseIds.size > 0 || selectedUnitIds.size > 0 || selectedLessonIds.size > 0
|
||||
);
|
||||
|
||||
const selectedCategoryId = watch("tier_category_id");
|
||||
@@ -190,10 +197,14 @@ export default function AddPlan() {
|
||||
return categories.find((c) => String(c.tier_category_id) === selectedCategoryId)?.slug ?? null;
|
||||
}, [selectedCategoryId, categories]);
|
||||
|
||||
// Reset picker when category changes
|
||||
// Reset pickers when category changes
|
||||
useEffect(() => {
|
||||
setSelectedCourseIds(new Set());
|
||||
setCourseConflicts(0);
|
||||
setSelectedUnitIds(new Set());
|
||||
setUnitConflicts(0);
|
||||
setSelectedLessonIds(new Set());
|
||||
setLessonConflicts(0);
|
||||
}, [categorySlug]);
|
||||
|
||||
const STEP_FIELDS = [
|
||||
@@ -220,12 +231,22 @@ export default function AddPlan() {
|
||||
const result = await createPlan(values);
|
||||
if (!result) return;
|
||||
|
||||
// Sync selected courses
|
||||
// Sync selected bundles
|
||||
if (selectedCourseIds.size > 0) {
|
||||
await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
|
||||
course_ids: [...selectedCourseIds].map(Number),
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (selectedUnitIds.size > 0) {
|
||||
await api.post(`/admin/tiers/plans/${result.plan_id}/units`, {
|
||||
unit_ids: [...selectedUnitIds].map(Number),
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (selectedLessonIds.size > 0) {
|
||||
await api.post(`/admin/tiers/plans/${result.plan_id}/lessons`, {
|
||||
lesson_ids: [...selectedLessonIds].map(Number),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
bypassOnce();
|
||||
navigate(`/admin/tiers/plans`);
|
||||
@@ -401,15 +422,31 @@ export default function AddPlan() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Assigned Courses ── */}
|
||||
{/* ── Step 2: Bundles ── */}
|
||||
{currentStep === 2 && (
|
||||
<SectionCard title="Assigned Courses" description="Choose which courses this plan unlocks.">
|
||||
<SectionCard title="Bundles" description="Choose which courses, units, and lessons this plan unlocks.">
|
||||
<CoursePicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
onConflictsChange={setCourseConflicts}
|
||||
/>
|
||||
<div className="border-t pt-5">
|
||||
<UnitPicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedUnitIds}
|
||||
onChange={setSelectedUnitIds}
|
||||
onConflictsChange={setUnitConflicts}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t pt-5">
|
||||
<LessonPicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedLessonIds}
|
||||
onChange={setSelectedLessonIds}
|
||||
onConflictsChange={setLessonConflicts}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
@@ -432,7 +469,7 @@ export default function AddPlan() {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={loading || catLoading || !selectedCategoryId || courseConflicts > 0}
|
||||
disabled={loading || catLoading || !selectedCategoryId || courseConflicts > 0 || unitConflicts > 0 || lessonConflicts > 0}
|
||||
>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Plan
|
||||
|
||||
@@ -17,6 +17,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||
import { UnitPicker } from "@/modules/admin/components/tiers/UnitPicker";
|
||||
import { LessonPicker } from "@/modules/admin/components/tiers/LessonPicker";
|
||||
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
|
||||
import api from "@/utils/api.util";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
@@ -87,6 +89,12 @@ export default function EditPlan() {
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
||||
const [courseConflicts, setCourseConflicts] = useState(0);
|
||||
const [selectedUnitIds, setSelectedUnitIds] = useState(new Set());
|
||||
const [unitsLoaded, setUnitsLoaded] = useState(false);
|
||||
const [unitConflicts, setUnitConflicts] = useState(0);
|
||||
const [selectedLessonIds, setSelectedLessonIds] = useState(new Set());
|
||||
const [lessonsLoaded, setLessonsLoaded] = useState(false);
|
||||
const [lessonConflicts, setLessonConflicts] = useState(0);
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [impactDialog, setImpactDialog] = useState(false);
|
||||
const [impactCount, setImpactCount] = useState(0);
|
||||
@@ -150,6 +158,29 @@ export default function EditPlan() {
|
||||
.catch(() => setCoursesLoaded(true));
|
||||
}, [planId]);
|
||||
|
||||
// Load existing assigned units/lessons the same way
|
||||
useEffect(() => {
|
||||
if (!planId || unitsLoaded) return;
|
||||
api.get(`/admin/tiers/${planId}/units`)
|
||||
.then(({ data }) => {
|
||||
const ids = (data.data ?? []).map((u) => String(u.unit_id));
|
||||
setSelectedUnitIds(new Set(ids));
|
||||
setUnitsLoaded(true);
|
||||
})
|
||||
.catch(() => setUnitsLoaded(true));
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!planId || lessonsLoaded) return;
|
||||
api.get(`/admin/tiers/${planId}/lessons`)
|
||||
.then(({ data }) => {
|
||||
const ids = (data.data ?? []).map((l) => String(l.lesson_id));
|
||||
setSelectedLessonIds(new Set(ids));
|
||||
setLessonsLoaded(true);
|
||||
})
|
||||
.catch(() => setLessonsLoaded(true));
|
||||
}, [planId]);
|
||||
|
||||
const durationChanged = (values) => {
|
||||
if (!plan) return false;
|
||||
const originalUnit = plan.duration_unit ?? "day";
|
||||
@@ -166,6 +197,12 @@ export default function EditPlan() {
|
||||
await api.post(`/admin/tiers/${planId}/courses`, {
|
||||
course_ids: [...selectedCourseIds],
|
||||
}).catch(() => {});
|
||||
await api.post(`/admin/tiers/${planId}/units`, {
|
||||
unit_ids: [...selectedUnitIds],
|
||||
}).catch(() => {});
|
||||
await api.post(`/admin/tiers/${planId}/lessons`, {
|
||||
lesson_ids: [...selectedLessonIds],
|
||||
}).catch(() => {});
|
||||
bypassOnce();
|
||||
navigate("/admin/tiers/plans");
|
||||
};
|
||||
@@ -339,7 +376,7 @@ export default function EditPlan() {
|
||||
</SectionCard>
|
||||
|
||||
{plan?.tier && (
|
||||
<SectionCard title="Assigned Courses">
|
||||
<SectionCard title="Bundles" description="Choose which courses, units, and lessons this plan unlocks.">
|
||||
{coursesLoaded ? (
|
||||
<CoursePicker
|
||||
subscription={plan.tier}
|
||||
@@ -358,12 +395,54 @@ export default function EditPlan() {
|
||||
<Skeleton className="h-8 w-52" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-5">
|
||||
{unitsLoaded ? (
|
||||
<UnitPicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedUnitIds}
|
||||
onChange={setSelectedUnitIds}
|
||||
isPreloaded={true}
|
||||
currentPlanId={planId}
|
||||
onConflictsChange={setUnitConflicts}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-52" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-5">
|
||||
{lessonsLoaded ? (
|
||||
<LessonPicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedLessonIds}
|
||||
onChange={setSelectedLessonIds}
|
||||
isPreloaded={true}
|
||||
currentPlanId={planId}
|
||||
onConflictsChange={setLessonConflicts}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-52" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate("/admin/tiers/plans")} disabled={loading || impactLoading}>Cancel</Button>
|
||||
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0}>
|
||||
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0 || unitConflicts > 0 || lessonConflicts > 0}>
|
||||
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
|
||||
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock,
|
||||
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock, FileText,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -70,7 +70,7 @@ function formatCourseDuration(seconds = 0) {
|
||||
|
||||
// ─── Tab: Plan Details ─────────────────────────────────────────────────────────
|
||||
|
||||
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading }) {
|
||||
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading, assignedUnits, unitsLoading, assignedLessons, lessonsLoading }) {
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
if (loading && !plan) {
|
||||
@@ -159,6 +159,68 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={BookOpen} title="Assigned Units">
|
||||
{unitsLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : assignedUnits.length === 0 ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<BookOpen className="size-4 shrink-0" />
|
||||
No units assigned to this plan yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
|
||||
{assignedUnits.map((unit) => (
|
||||
<div key={unit.unit_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||
<BookOpen className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium line-clamp-1">{unit.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!unitsLoading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignedUnits.length} unit{assignedUnits.length !== 1 ? "s" : ""} assigned
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={FileText} title="Assigned Lessons">
|
||||
{lessonsLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : assignedLessons.length === 0 ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<FileText className="size-4 shrink-0" />
|
||||
No lessons assigned to this plan yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
|
||||
{assignedLessons.map((lesson) => (
|
||||
<div key={lesson.lesson_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium line-clamp-1">{lesson.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!lessonsLoading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignedLessons.length} lesson{assignedLessons.length !== 1 ? "s" : ""} assigned
|
||||
</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={BadgeCheck} title="Audit">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Created At">{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}</InfoRow>
|
||||
@@ -750,6 +812,10 @@ export default function ViewPlan() {
|
||||
|
||||
const [assignedCourses, setAssignedCourses] = useState([]);
|
||||
const [coursesLoading, setCoursesLoading] = useState(false);
|
||||
const [assignedUnits, setAssignedUnits] = useState([]);
|
||||
const [unitsLoading, setUnitsLoading] = useState(false);
|
||||
const [assignedLessons, setAssignedLessons] = useState([]);
|
||||
const [lessonsLoading, setLessonsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
@@ -759,6 +825,16 @@ export default function ViewPlan() {
|
||||
.then(({ data }) => setAssignedCourses(data.data ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setCoursesLoading(false));
|
||||
setUnitsLoading(true);
|
||||
api.get(`/admin/tiers/${planId}/units`)
|
||||
.then(({ data }) => setAssignedUnits(data.data ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setUnitsLoading(false));
|
||||
setLessonsLoading(true);
|
||||
api.get(`/admin/tiers/${planId}/lessons`)
|
||||
.then(({ data }) => setAssignedLessons(data.data ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLessonsLoading(false));
|
||||
}, [planId]);
|
||||
|
||||
return (
|
||||
@@ -835,6 +911,10 @@ export default function ViewPlan() {
|
||||
tierMap={tierMap}
|
||||
assignedCourses={assignedCourses}
|
||||
coursesLoading={coursesLoading}
|
||||
assignedUnits={assignedUnits}
|
||||
unitsLoading={unitsLoading}
|
||||
assignedLessons={assignedLessons}
|
||||
lessonsLoading={lessonsLoading}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "access" && (
|
||||
|
||||
Reference in New Issue
Block a user