units,lesson as standalone

This commit is contained in:
2026-07-10 11:45:02 +08:00
parent 182bd93d10
commit 01a2c63b06
60 changed files with 3217 additions and 606 deletions
+46 -5
View File
@@ -1,9 +1,9 @@
import { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, ArrowRight, Check, House } from "lucide-react";
import { ArrowLeft, ArrowRight, Check, House, Plus, Trash2 } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -38,6 +38,7 @@ const schema = z.object({
tier_category_id: z.string().min(1, "Tier category is required."),
label: z.string().min(1, "Label is required."),
description: z.string().optional(),
features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]),
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
duration_unit: z.string().min(1),
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
@@ -153,11 +154,14 @@ export default function AddPlan() {
.catch(() => {});
}, []);
const { register, handleSubmit, trigger, setValue, watch, formState: { errors } } = useForm({
const { register, handleSubmit, trigger, setValue, watch, control, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { tier_category_id: "", label: "", description: "", duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
defaultValues: { tier_category_id: "", label: "", description: "", features: [], duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
});
const { fields: featureFields, append: appendFeature, remove: removeFeature } =
useFieldArray({ control, name: "features" });
const selectedCategoryId = watch("tier_category_id");
// Derive the subscription slug from the chosen category
@@ -173,7 +177,7 @@ export default function AddPlan() {
}, [categorySlug]);
const STEP_FIELDS = [
["tier_category_id", "label", "description"],
["tier_category_id", "label", "description", "features"],
["duration_value", "duration_unit", "price", "currency"],
[],
];
@@ -286,6 +290,43 @@ export default function AddPlan() {
/>
<FieldError message={errors.description?.message} />
</div>
<div className="space-y-2">
<Label>What's included</Label>
<p className="text-xs text-muted-foreground -mt-1">
Bullet points shown on the plans page and the comparison table.
</p>
{featureFields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
placeholder={`e.g. Access to all Premium courses`}
{...register(`features.${index}.text`)}
/>
<FieldError message={errors.features?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeFeature(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => appendFeature({ text: "" })}
>
<Plus className="h-4 w-4 mr-2" />
Add Feature
</Button>
</div>
</SectionCard>
)}
+45 -3
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House, TriangleAlert } from "lucide-react";
import { ArrowLeft, House, TriangleAlert, Plus, Trash2 } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -45,6 +45,7 @@ const DURATION_UNIT_LIMITS = {
const schema = z.object({
label: z.string().min(1, "Label is required."),
description: z.string().optional(),
features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]),
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
duration_unit: z.string().min(1),
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
@@ -91,10 +92,13 @@ export default function EditPlan() {
const [impactLoading, setImpactLoading] = useState(false);
const [pendingValues, setPendingValues] = useState(null);
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
const { register, handleSubmit, setValue, watch, reset, control, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
const { fields: featureFields, append: appendFeature, remove: removeFeature } =
useFieldArray({ control, name: "features" });
useEffect(() => {
fetchPlan(planId);
api.get("/admin/tiers/currencies")
@@ -108,6 +112,7 @@ export default function EditPlan() {
reset({
label: plan.label,
description: plan.description ?? "",
features: (plan.features ?? []).map((f) => (typeof f === "string" ? { text: f } : f)),
duration_value: durationDaysToValue(plan.duration_days, unit),
duration_unit: unit,
price: plan.price,
@@ -223,6 +228,43 @@ export default function EditPlan() {
<FieldError message={errors.description?.message} />
</div>
<div className="space-y-2">
<Label>What's included</Label>
<p className="text-xs text-muted-foreground -mt-1">
Bullet points shown on the plans page and the comparison table.
</p>
{featureFields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
placeholder="e.g. Access to all Premium courses"
{...register(`features.${index}.text`)}
/>
<FieldError message={errors.features?.[index]?.text?.message} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeFeature(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => appendFeature({ text: "" })}
>
<Plus className="h-4 w-4 mr-2" />
Add Feature
</Button>
</div>
<div className="space-y-1.5">
<Label>Duration</Label>
<div className="flex gap-2">
+240 -1
View File
@@ -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,
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -481,6 +481,241 @@ function PaymentPolicyTab({ planId, plan }) {
);
}
// ─── Tab: Access Rules ─────────────────────────────────────────────────────────
// Configures plan_policies.access_rules — evaluateCourseAccess (utils/accessPolicy.util.js)
// reads these instead of falling back to plain tier-rank comparison once any
// rule exists here. Empty (default) = unchanged rank-comparison behavior.
const RULE_TYPES = [
{ value: "course_subscription_access", label: "Allowed subscription levels", icon: Tag,
description: "Only grant access to courses at these subscription levels." },
{ value: "required_active_tier", label: "Required active tier", icon: KeyRound,
description: "User's active tier must be at least this rank." },
{ value: "group_restriction", label: "Group restriction", icon: Users,
description: "User must belong to at least one of these groups." },
];
function ruleSummary(rule, tierCategories, groups) {
if (rule.type === "course_subscription_access") {
const names = (rule.levels ?? []).map((slug) => tierCategories.find((c) => c.slug === slug)?.name ?? slug);
return `Allowed levels: ${names.join(", ") || "—"}`;
}
if (rule.type === "required_active_tier") {
return `Requires active tier: ${tierCategories.find((c) => c.slug === rule.tier)?.name ?? rule.tier}`;
}
if (rule.type === "group_restriction") {
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 rule.type;
}
function AccessRulesTab({ planId }) {
const [rules, setRules] = useState([]);
const [rulesLoading, setRulesLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [tierCategories, setTierCategories] = useState([]);
const [groups, setGroups] = useState([]);
const [showAdd, setShowAdd] = useState(false);
const [newType, setNewType] = useState("course_subscription_access");
const [newLevels, setNewLevels] = useState([]);
const [newTier, setNewTier] = useState("");
const [newGroupIds, setNewGroupIds] = useState([]);
useEffect(() => {
setRulesLoading(true);
api.get(`/admin/tier-policies/plans/${planId}/policy`)
.then(({ data }) => setRules(data.data?.access_rules ?? []))
.catch(() => {})
.finally(() => setRulesLoading(false));
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
api.get("/admin/groups", { params: { limit: 100 } })
.then(({ data }) => setGroups(data.data?.data ?? []))
.catch(() => {});
}, [planId]);
const handleSave = async (next) => {
setSaving(true);
try {
await api.put(`/admin/tier-policies/plans/${planId}/policy`, { access_rules: next });
setRules(next);
toast("Access rules saved.");
} catch (err) {
toast(err?.response?.data?.message ?? "Could not save access rules.");
} finally {
setSaving(false);
}
};
const resetAddForm = () => {
setShowAdd(false);
setNewType("course_subscription_access");
setNewLevels([]);
setNewTier("");
setNewGroupIds([]);
};
const handleAddRule = () => {
let rule;
if (newType === "course_subscription_access") {
if (!newLevels.length) { toast("Select at least one subscription level."); return; }
rule = { type: newType, levels: newLevels };
} else if (newType === "required_active_tier") {
if (!newTier) { toast("Select a required tier."); return; }
rule = { type: newType, tier: newTier };
} else {
if (!newGroupIds.length) { toast("Select at least one group."); return; }
rule = { type: newType, group_ids: newGroupIds.map(Number) };
}
handleSave([...rules, rule]);
resetAddForm();
};
const handleRemoveRule = (index) => {
handleSave(rules.filter((_, i) => i !== index));
};
if (rulesLoading) {
return (
<div className="space-y-4">
{[...Array(2)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
);
}
return (
<div className="space-y-5">
<SectionCard
icon={Lock}
title="Access Rules"
description="Overrides the default rank-comparison access check for this plan. Leave empty to use plain tier-rank comparison."
>
{rules.length > 0 ? (
<div className="space-y-2">
{rules.map((rule, i) => {
const meta = RULE_TYPES.find((t) => t.value === rule.type);
const Icon = meta?.icon ?? Lock;
return (
<div key={i} className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<Icon className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm">{ruleSummary(rule, tierCategories, groups)}</span>
</div>
<Button
variant="ghost" size="icon"
className="text-destructive hover:text-destructive shrink-0"
disabled={saving}
onClick={() => handleRemoveRule(i)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Lock className="size-4 shrink-0" />
No access rules configured — falls back to plain tier-rank comparison.
</div>
)}
{showAdd ? (
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
<p className="text-sm font-medium">New Access Rule</p>
<div className="space-y-1.5">
<Label>Rule Type</Label>
<Select value={newType} onValueChange={setNewType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{RULE_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{RULE_TYPES.find((t) => t.value === newType)?.description}
</p>
</div>
{newType === "course_subscription_access" && (
<div className="space-y-1.5">
<Label>Allowed Levels</Label>
<div className="flex flex-wrap gap-2">
{tierCategories.map((c) => (
<Badge
key={c.slug}
variant={newLevels.includes(c.slug) ? "default" : "outline"}
className="cursor-pointer select-none"
onClick={() => setNewLevels((prev) =>
prev.includes(c.slug) ? prev.filter((s) => s !== c.slug) : [...prev, c.slug]
)}
>
{c.name}
</Badge>
))}
</div>
</div>
)}
{newType === "required_active_tier" && (
<div className="space-y-1.5">
<Label>Required Tier</Label>
<Select value={newTier} onValueChange={setNewTier}>
<SelectTrigger><SelectValue placeholder="Select a tier" /></SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{newType === "group_restriction" && (
<div className="space-y-1.5">
<Label>Groups</Label>
<div className="flex flex-wrap gap-2">
{groups.map((g) => (
<Badge
key={g.group_id}
variant={newGroupIds.includes(String(g.group_id)) ? "default" : "outline"}
className="cursor-pointer select-none"
onClick={() => setNewGroupIds((prev) =>
prev.includes(String(g.group_id))
? prev.filter((id) => id !== String(g.group_id))
: [...prev, String(g.group_id)]
)}
>
{g.name}
</Badge>
))}
{groups.length === 0 && (
<p className="text-xs text-muted-foreground">No groups found.</p>
)}
</div>
</div>
)}
<div className="flex gap-2 justify-end pt-1">
<Button variant="outline" size="sm" onClick={resetAddForm}>Cancel</Button>
<Button size="sm" onClick={handleAddRule} disabled={saving}>
<Plus className="h-4 w-4 mr-1" /> Add Rule
</Button>
</div>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => setShowAdd(true)}>
<Plus className="h-4 w-4 mr-1" /> Add Access Rule
</Button>
)}
</SectionCard>
</div>
);
}
// ─── Tab: Payments ─────────────────────────────────────────────────────────────
function PaymentsTab({ planId }) {
@@ -497,6 +732,7 @@ function PaymentsTab({ planId }) {
const TABS = [
{ key: "details", label: "Plan Details", icon: CreditCard },
{ key: "access", label: "Access Rules", icon: Lock },
{ key: "policy", label: "Payment Policy", icon: ShieldCheck },
{ key: "payments", label: "Payments", icon: Receipt },
];
@@ -601,6 +837,9 @@ export default function ViewPlan() {
coursesLoading={coursesLoading}
/>
)}
{activeTab === "access" && (
<AccessRulesTab planId={planId} />
)}
{activeTab === "policy" && (
<PaymentPolicyTab planId={planId} plan={plan} />
)}