mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -1,298 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { ArrowLeft, House, ShieldCheck, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import {
|
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import { AdminTierPoliciesProvider, useAdminTierPolicies } from "@/contexts/AdminTierPoliciesContext";
|
|
||||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
|
||||||
import api from "@/utils/api.util";
|
|
||||||
|
|
||||||
// ─── Rule type definitions ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const RULE_DEFS = {
|
|
||||||
course_subscription_access: {
|
|
||||||
label: "Course Subscription Access",
|
|
||||||
description: "Which course subscription levels this plan can unlock.",
|
|
||||||
default: { type: "course_subscription_access", levels: ["free"] },
|
|
||||||
},
|
|
||||||
required_active_tier: {
|
|
||||||
label: "Required Active Tier",
|
|
||||||
description: "User's tier must be at least this rank to access content.",
|
|
||||||
default: { type: "required_active_tier", tier: "" },
|
|
||||||
},
|
|
||||||
group_restriction: {
|
|
||||||
label: "Group Restriction",
|
|
||||||
description: "Only users in selected groups can access content.",
|
|
||||||
default: { type: "group_restriction", group_ids: [] },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Rule Editors ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CourseAccessEditor({ rule, onChange }) {
|
|
||||||
const [availableLevels, setAvailableLevels] = useState(["free"]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.get("/admin/tiers/categories")
|
|
||||||
.then(({ data }) => {
|
|
||||||
setAvailableLevels((data.data ?? []).filter((c) => c.is_active).map((c) => c.slug));
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggle = (level) => {
|
|
||||||
const levels = rule.levels ?? [];
|
|
||||||
onChange({ ...rule, levels: levels.includes(level) ? levels.filter((l) => l !== level) : [...levels, level] });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
{availableLevels.map((lvl) => (
|
|
||||||
<button
|
|
||||||
key={lvl}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggle(lvl)}
|
|
||||||
className={`px-3 py-1 rounded-full border text-sm capitalize transition-colors ${
|
|
||||||
(rule.levels ?? []).includes(lvl)
|
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-card border-border hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{lvl}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RequiredTierEditor({ rule, onChange }) {
|
|
||||||
const [categories, setCategories] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.get("/admin/tiers/categories")
|
|
||||||
.then(({ data }) => setCategories((data.data ?? []).filter((c) => c.is_active && !c.is_default)))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select value={rule.tier} onValueChange={(v) => onChange({ ...rule, tier: v })}>
|
|
||||||
<SelectTrigger className="w-48 mt-2">
|
|
||||||
<SelectValue placeholder="Select tier" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{categories.map((c) => (
|
|
||||||
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupRestrictionEditor({ rule, onChange }) {
|
|
||||||
const [groups, setGroups] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.get("/admin/groups").then(({ data }) => setGroups(data.data?.data ?? [])).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleGroup = (gid) => {
|
|
||||||
const ids = rule.group_ids ?? [];
|
|
||||||
onChange({ ...rule, group_ids: ids.includes(gid) ? ids.filter((id) => id !== gid) : [...ids, gid] });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 space-y-1 max-h-48 overflow-y-auto">
|
|
||||||
{groups.length === 0 && <p className="text-xs text-muted-foreground">No groups found.</p>}
|
|
||||||
{groups.map((g) => {
|
|
||||||
const selected = (rule.group_ids ?? []).includes(Number(g.group_id));
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={g.group_id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleGroup(Number(g.group_id))}
|
|
||||||
className={`w-full text-left px-3 py-1.5 rounded border text-sm transition-colors ${
|
|
||||||
selected ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{g.name}
|
|
||||||
{g.group_code && <span className="ml-2 text-xs opacity-60">{g.group_code}</span>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RuleCard({ rule, index, onChange, onRemove }) {
|
|
||||||
const def = RULE_DEFS[rule.type];
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-4 space-y-2">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">{def?.label ?? rule.type}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{def?.description}</p>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="ghost" size="icon" className="shrink-0" onClick={onRemove}>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{rule.type === "course_subscription_access" && <CourseAccessEditor rule={rule} onChange={(r) => onChange(index, r)} />}
|
|
||||||
{rule.type === "required_active_tier" && <RequiredTierEditor rule={rule} onChange={(r) => onChange(index, r)} />}
|
|
||||||
{rule.type === "group_restriction" && <GroupRestrictionEditor rule={rule} onChange={(r) => onChange(index, r)} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionCard({ icon: Icon, title, children }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
|
||||||
<h2 className="text-sm font-semibold">{title}</h2>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Inner page ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function PlanPolicyInner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { planId } = useParams();
|
|
||||||
const { fetchPlan, plan, loading: planLoading } = useTiers();
|
|
||||||
const { fetchPlanPolicy, savePlanPolicy, policy, loading } = useAdminTierPolicies();
|
|
||||||
|
|
||||||
const [rules, setRules] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPlan(planId);
|
|
||||||
fetchPlanPolicy(planId);
|
|
||||||
}, [planId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (policy) setRules(policy.access_rules ?? []);
|
|
||||||
}, [policy]);
|
|
||||||
|
|
||||||
const handleRuleChange = (index, updated) =>
|
|
||||||
setRules((prev) => prev.map((r, i) => (i === index ? updated : r)));
|
|
||||||
|
|
||||||
const removeRule = (index) =>
|
|
||||||
setRules((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
|
|
||||||
const addRule = (type) => {
|
|
||||||
const def = RULE_DEFS[type];
|
|
||||||
if (!def) return;
|
|
||||||
setRules((prev) => [...prev, { ...def.default }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const usedTypes = new Set(rules.map((r) => r.type));
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
await savePlanPolicy(planId, { access_rules: rules });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-muted/60 min-h-full">
|
|
||||||
<PageMeta title={plan ? `Policy — ${plan.label}` : "Plan Policy"} />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
|
||||||
<div className="w-full max-w-2xl mx-auto">
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<AppBreadcrumb items={[
|
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
||||||
{ label: "Plans", to: "/admin/tiers/plans" },
|
|
||||||
{ label: plan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
|
|
||||||
{ label: "Policy" },
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-3 mb-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">Access Policy</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{planLoading ? <Skeleton className="h-4 w-40 inline-block" /> : (plan?.label ?? `Plan #${planId}`)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSave} disabled={loading} size="sm">
|
|
||||||
{loading ? "Saving…" : "Save Policy"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
|
|
||||||
<SectionCard icon={ShieldCheck} title="Access Rules">
|
|
||||||
{rules.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No rules defined. Content access falls back to tier rank comparison.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{rules.map((rule, i) => (
|
|
||||||
<RuleCard
|
|
||||||
key={`${rule.type}-${i}`}
|
|
||||||
rule={rule}
|
|
||||||
index={i}
|
|
||||||
onChange={handleRuleChange}
|
|
||||||
onRemove={() => removeRule(i)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Add Rule</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Object.entries(RULE_DEFS).map(([type, def]) => (
|
|
||||||
<Button
|
|
||||||
key={type}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={usedTypes.has(type)}
|
|
||||||
onClick={() => addRule(type)}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
{def.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{plan && (
|
|
||||||
<div className="flex items-center gap-2 px-1">
|
|
||||||
<Badge variant="outline" className="capitalize">{plan.tier}</Badge>
|
|
||||||
<span className="text-sm text-muted-foreground">{plan.label}</span>
|
|
||||||
<span className="text-sm text-muted-foreground">·</span>
|
|
||||||
<span className="text-sm text-muted-foreground">{plan.duration_days} days</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlanPolicy() {
|
|
||||||
return (
|
|
||||||
<AdminTierPoliciesProvider>
|
|
||||||
<PlanPolicyInner />
|
|
||||||
</AdminTierPoliciesProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, ShieldCheck } from "lucide-react";
|
import { ArrowLeft, House, Pencil, Tag, BadgeCheck } from "lucide-react";
|
||||||
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 { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -86,15 +86,6 @@ export default function ViewPlan() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate(`/admin/tiers/plans/${planId}/policy`)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
|
||||||
Policy
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user