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:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
@@ -14,12 +14,16 @@ import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import DraftRequirementsEditor, { DraftRequirementsSummary } from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription,
|
||||
DrawerFooter, DrawerClose,
|
||||
@@ -36,10 +40,11 @@ function makeBlock(type) {
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
subscription: z.string().optional(),
|
||||
blocks: z.array(z.any()).optional(),
|
||||
});
|
||||
|
||||
const DEFAULT_VALUES = { title: "", description: "", blocks: [] };
|
||||
const DEFAULT_VALUES = { title: "", description: "", subscription: "", blocks: [] };
|
||||
|
||||
const STEPS = [
|
||||
{ id: 0, label: "Lesson", icon: FileText },
|
||||
@@ -50,7 +55,7 @@ const STEPS = [
|
||||
|
||||
// Fields validated with trigger() before advancing past each step.
|
||||
// Empty array means "validate the whole form" (nothing new to check that step).
|
||||
const STEP_FIELDS = [["title", "description"], [], []];
|
||||
const STEP_FIELDS = [["title", "description", "subscription"], [], []];
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
@@ -58,7 +63,9 @@ function FieldError({ message }) {
|
||||
}
|
||||
|
||||
// ─── Step 1 — Lesson ───────────────────────────────────────────────────────────
|
||||
function StepLesson({ register, errors }) {
|
||||
function StepLesson({ register, errors, control, setValue, tierCategories }) {
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
@@ -71,6 +78,29 @@ function StepLesson({ register, errors }) {
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || "__open"}
|
||||
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No tier gate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__open">No tier gate (open)</SelectItem>
|
||||
{tierCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Gates this lesson directly, independent of any unit it may later be attached to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -183,7 +213,9 @@ function SummaryRow({ label, value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StepReview({ data, attachUnitId, requirements }) {
|
||||
function StepReview({ data, attachUnitId, requirements, tierCategories }) {
|
||||
const tierName = tierCategories.find((c) => c.slug === data.subscription)?.name;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
||||
@@ -193,6 +225,7 @@ function StepReview({ data, attachUnitId, requirements }) {
|
||||
</div>
|
||||
<SummaryRow label="Title" value={data.title} />
|
||||
<SummaryRow label="Description" value={data.description} />
|
||||
<SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} />
|
||||
<SummaryRow label="Page content" value={`${(data.blocks ?? []).length} block(s)`} />
|
||||
{attachUnitId && <SummaryRow label="Attaches to" value="The unit you came from" />}
|
||||
</div>
|
||||
@@ -215,6 +248,13 @@ export default function AddLibraryLesson() {
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [requirements, setRequirements] = useState([]);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
register, control, trigger, getValues, setValue,
|
||||
@@ -249,6 +289,7 @@ export default function AddLibraryLesson() {
|
||||
const result = await createLesson({
|
||||
title: data.title,
|
||||
description: data.description || null,
|
||||
subscription: data.subscription || null,
|
||||
...(attachUnitId ? { unit_id: attachUnitId } : {}),
|
||||
createdBy: user?.user_id,
|
||||
});
|
||||
@@ -332,7 +373,7 @@ export default function AddLibraryLesson() {
|
||||
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
|
||||
|
||||
{step === 0 && (
|
||||
<StepLesson register={register} errors={errors} />
|
||||
<StepLesson register={register} errors={errors} control={control} setValue={setValue} tierCategories={tierCategories} />
|
||||
)}
|
||||
{step === 1 && (
|
||||
<StepPageBuilder control={control} setValue={setValue} getValues={getValues} />
|
||||
@@ -345,7 +386,7 @@ export default function AddLibraryLesson() {
|
||||
/>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<StepReview data={getValues()} attachUnitId={attachUnitId} requirements={requirements} />
|
||||
<StepReview data={getValues()} attachUnitId={attachUnitId} requirements={requirements} tierCategories={tierCategories} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
@@ -9,17 +9,23 @@ import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||
import ProductPricingCard from "@/modules/admin/components/products/ProductPricingCard";
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
subscription: z.string().optional(),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
@@ -30,29 +36,38 @@ function FieldError({ message }) {
|
||||
export default function EditLibraryLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { lessonId } = useParams();
|
||||
const { fetchLesson, updateLesson, lesson, loading } = useLibrary();
|
||||
const { fetchLesson, updateLesson, lesson, loading, fetchLessonProduct, saveLessonProduct, removeLessonProduct } = useLibrary();
|
||||
const { fetchLessonRequirements, syncLessonRequirements } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const { register, handleSubmit, reset, control, setValue, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "" },
|
||||
defaultValues: { title: "", description: "", subscription: "" },
|
||||
});
|
||||
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
|
||||
useEffect(() => {
|
||||
fetchLesson(lessonId);
|
||||
}, [lessonId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lesson && String(lesson.lesson_id) === String(lessonId)) {
|
||||
reset({ title: lesson.title ?? "", description: lesson.description ?? "" });
|
||||
reset({ title: lesson.title ?? "", description: lesson.description ?? "", subscription: lesson.subscription ?? "" });
|
||||
}
|
||||
}, [lesson, lessonId, reset]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await updateLesson(lessonId, { ...data, updatedBy: user?.user_id });
|
||||
const result = await updateLesson(lessonId, { ...data, subscription: data.subscription || null, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
bypassOnce();
|
||||
navigate(`/admin/lessons/${lessonId}/view`);
|
||||
@@ -90,6 +105,29 @@ export default function EditLibraryLesson() {
|
||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || "__open"}
|
||||
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No tier gate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__open">No tier gate (open)</SelectItem>
|
||||
{tierCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Gates this lesson directly, independent of any unit it may later be attached to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
@@ -115,6 +153,20 @@ export default function EditLibraryLesson() {
|
||||
args={[null, null, lessonId]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4 mt-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Pricing</h2>
|
||||
<p className="text-xs text-muted-foreground">Optional individual-purchase listing for this lesson.</p>
|
||||
</div>
|
||||
<ProductPricingCard
|
||||
label="this lesson"
|
||||
fetchFn={fetchLessonProduct}
|
||||
saveFn={saveLessonProduct}
|
||||
removeFn={removeLessonProduct}
|
||||
args={[lessonId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function ViewLibraryLesson() {
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Blocks</p>
|
||||
<p className="font-semibold text-sm">{blocks.length}</p>
|
||||
@@ -97,6 +97,12 @@ export default function ViewLibraryLesson() {
|
||||
{units.length > 0 ? `${units.length} unit${units.length === 1 ? "" : "s"}` : "Standalone"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Subscription</p>
|
||||
<Badge variant={lesson?.subscription ? "outline" : "secondary"} className="capitalize">
|
||||
{lesson?.subscription ?? "No tier gate"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attached units */}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||
import ProductPricingCard from "@/modules/admin/components/products/ProductPricingCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -35,7 +36,7 @@ function FieldError({ message }) {
|
||||
export default function EditLibraryUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { unitId } = useParams();
|
||||
const { fetchUnit, updateUnit, unit, loading } = useLibrary();
|
||||
const { fetchUnit, updateUnit, unit, loading, fetchUnitProduct, saveUnitProduct, removeUnitProduct } = useLibrary();
|
||||
const { fetchUnitRequirements, syncUnitRequirements } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -152,6 +153,20 @@ export default function EditLibraryUnit() {
|
||||
args={[null, unitId]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4 mt-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Pricing</h2>
|
||||
<p className="text-xs text-muted-foreground">Optional individual-purchase listing for this unit.</p>
|
||||
</div>
|
||||
<ProductPricingCard
|
||||
label="this unit"
|
||||
fetchFn={fetchUnitProduct}
|
||||
saveFn={saveUnitProduct}
|
||||
removeFn={removeUnitProduct}
|
||||
args={[unitId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function ViewLibraryUnit() {
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Lessons</p>
|
||||
<p className="font-semibold text-sm">{lessons.length}</p>
|
||||
@@ -125,6 +125,12 @@ export default function ViewLibraryUnit() {
|
||||
{courses.length > 0 ? `${courses.length} course${courses.length === 1 ? "" : "s"}` : "Standalone"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Subscription</p>
|
||||
<Badge variant={unit?.subscription ? "outline" : "secondary"} className="capitalize">
|
||||
{unit?.subscription ?? "No tier gate"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attached courses */}
|
||||
|
||||
Reference in New Issue
Block a user