pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:31:37 +08:00
parent bac7168b1e
commit 9e2eecf9df
2 changed files with 1 additions and 308 deletions
@@ -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 -10
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useMemo } from "react";
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 { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -86,15 +86,6 @@ export default function ViewPlan() {
</div>
</div>
<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
variant="outline"
size="sm"