pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:30:01 +08:00
parent b03b204861
commit bac7168b1e
100 changed files with 5958 additions and 1976 deletions
@@ -0,0 +1,353 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, House, ShieldCheck, ImagePlus, X, Check,
Shield, Star, Trophy, Medal, Award, BadgeCheck, Gem,
Crown, Zap, Flame, Sparkles, Rocket, Target, Hexagon, Layers, CircleDot,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
import { Badge } from "@/components/ui/badge";
const BADGE_ICON_OPTIONS = [
{ name: "ShieldCheck", icon: ShieldCheck },
{ name: "Shield", icon: Shield },
{ name: "BadgeCheck", icon: BadgeCheck },
{ name: "Star", icon: Star },
{ name: "Crown", icon: Crown },
{ name: "Gem", icon: Gem },
{ name: "Trophy", icon: Trophy },
{ name: "Medal", icon: Medal },
{ name: "Award", icon: Award },
{ name: "Sparkles", icon: Sparkles },
{ name: "Flame", icon: Flame },
{ name: "Zap", icon: Zap },
{ name: "Rocket", icon: Rocket },
{ name: "Target", icon: Target },
{ name: "Hexagon", icon: Hexagon },
{ name: "Layers", icon: Layers },
{ name: "CircleDot", icon: CircleDot },
];
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 { Switch } from "@/components/ui/switch";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { AssetsProvider } from "@/contexts/AdminAssetsContext";
import {
AdminTierCategoriesProvider,
useAdminTierCategories,
} from "@/contexts/AdminTierCategoriesContext";
function SectionCard({ title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
{children}
</div>
);
}
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function BadgePicker({ currentAsset, selectedAsset, onSelect, onClear }) {
const [open, setOpen] = useState(false);
const display = selectedAsset ?? currentAsset;
return (
<div className="space-y-2">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
{display?.file_url ? (
<img src={display.file_url} alt={display.display_name} className="w-12 h-12 object-contain" />
) : (
<ShieldCheck className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="flex flex-col gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
{display ? "Change image" : "Pick from assets"}
</Button>
{display && (
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={onClear}>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove
</Button>
)}
</div>
</div>
{display && <p className="text-xs text-muted-foreground truncate max-w-xs">{display.display_name}</p>}
<AssetPickerSheet open={open} onOpenChange={setOpen} fileType="image" onSelect={onSelect} />
</div>
);
}
function EditTierCategoryInner({ isAdd }) {
const navigate = useNavigate();
const { id } = useParams();
const { category, loading, fetchCategory, createCategory, updateCategory } = useAdminTierCategories();
const [slug, setSlug] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [rank, setRank] = useState(1);
const [color, setColor] = useState("purple");
const [badgeIcon, setBadgeIcon] = useState(null);
const [badgeLabel, setBadgeLabel] = useState("");
const [isActive, setIsActive] = useState(true);
const [selectedAsset, setSelectedAsset] = useState(null);
const [clearBadge, setClearBadge] = useState(false);
const [errors, setErrors] = useState({});
useEffect(() => {
if (!isAdd && id) fetchCategory(id);
}, [id, isAdd]);
useEffect(() => {
if (category && !isAdd) {
setSlug(category.slug ?? "");
setName(category.name ?? "");
setDescription(category.description ?? "");
setRank(category.rank ?? 0);
setColor(category.color ?? (category.is_default ? "green" : "purple"));
setBadgeIcon(category.badge_icon ?? null);
setBadgeLabel(category.badge_label ?? "");
setIsActive(category.is_active ?? true);
setSelectedAsset(null);
setClearBadge(false);
}
}, [category, isAdd]);
const validate = () => {
const e = {};
if (!name.trim()) e.name = "Name is required.";
if (isAdd && !slug.trim()) e.slug = "Slug is required.";
if (isAdd && !/^[a-z0-9_-]+$/.test(slug)) e.slug = "Slug must be lowercase letters, numbers, hyphens or underscores.";
setErrors(e);
return !Object.keys(e).length;
};
const handleSave = async () => {
if (!validate()) return;
const payload = {
name: name.trim(),
description: description.trim() || null,
rank: Number(rank),
color,
badge_icon: badgeIcon || null,
badge_label: badgeLabel.trim() || null,
is_active: isActive,
};
if (selectedAsset) payload.badge_asset_id = selectedAsset.asset_id;
else if (clearBadge) payload.badge_asset_id = null;
if (isAdd) {
payload.slug = slug.trim();
const result = await createCategory(payload);
if (result) navigate("/admin/tiers/categories");
} else {
const result = await updateCategory(id, payload);
if (result) navigate("/admin/tiers/categories");
}
};
const isLocked = !isAdd && category?.is_default;
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={isAdd ? "Add Tier Category - STARR" : "Edit Tier Category - STARR"} />
<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: "Tier Categories", to: "/admin/tiers/categories" },
{ label: isAdd ? "Add Category" : (category?.name ?? "Edit") },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">{isAdd ? "Add Tier Category" : "Edit Tier Category"}</h1>
<p className="text-sm text-muted-foreground">
{isAdd ? "Define a new tier level for the platform." : "Update this tier category's details and badge."}
</p>
</div>
</div>
<div className="space-y-5">
{/* Details */}
<SectionCard title="Category Details">
{isAdd && (
<div className="space-y-1.5">
<Label htmlFor="slug">Slug <span className="text-destructive">*</span></Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase())}
placeholder="e.g. gold"
/>
<p className="text-xs text-muted-foreground">Lowercase, no spaces. Cannot be changed after creation.</p>
<FieldError message={errors.slug} />
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Gold" />
<FieldError message={errors.name} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="Optional short description." />
</div>
<div className="space-y-1.5">
<Label htmlFor="rank">Rank (ordering)</Label>
<Input id="rank" type="number" min={isLocked ? 0 : 1} value={rank} onChange={(e) => setRank(e.target.value)} className="w-32" />
<p className="text-xs text-muted-foreground">Must be greater than 0. Free is rank 0. Higher rank = higher access tier.</p>
{!isLocked && Number(rank) <= 0 && (
<p className="text-xs text-destructive">Rank must be at least 1 — rank 0 is reserved for the Free (default) tier.</p>
)}
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{TIER_COLOR_OPTIONS.map((opt) => {
const selected = color === opt.key;
return (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setColor(opt.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border-2 transition-all ${selected ? "border-foreground scale-105" : "border-transparent opacity-70 hover:opacity-100"}`}
style={{ backgroundColor: opt.swatch, color: "#fff" }}
>
{selected && <Check className="size-3" />}
{opt.label}
</button>
);
})}
</div>
<div className="pt-1">
<Badge className={`${getTierColor(color).badge} text-xs`}>
{name || "Preview"}
</Badge>
</div>
</div>
{!isLocked && (
<div className="flex items-center gap-3">
<Switch id="is_active" checked={isActive} onCheckedChange={setIsActive} />
<Label htmlFor="is_active">Active</Label>
</div>
)}
</SectionCard>
{/* Badge */}
<SectionCard title="Badge">
<p className="text-xs text-muted-foreground -mt-1">
Shown on the user's profile when they are in this tier. Upload an image or pick a Lucide icon — image takes priority if both are set.
</p>
{/* Lucide icon picker */}
<div className="space-y-2">
<Label>Icon <span className="text-muted-foreground text-xs">(optional)</span></Label>
<div className="flex flex-wrap gap-2">
{/* None option */}
<button
type="button"
onClick={() => setBadgeIcon(null)}
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 text-xs text-muted-foreground transition-all ${!badgeIcon ? "border-foreground bg-muted scale-105" : "border-border hover:border-muted-foreground"}`}
title="No icon"
>
<X className="size-3.5" />
</button>
{BADGE_ICON_OPTIONS.map(({ name, icon: Icon }) => {
const selected = badgeIcon === name;
const cls = getTierColor(color).badge;
return (
<button
key={name}
type="button"
title={name}
onClick={() => setBadgeIcon(name)}
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 transition-all ${selected ? `${cls} border-foreground scale-105` : "border-border hover:border-muted-foreground"}`}
>
<Icon className="size-4" />
</button>
);
})}
</div>
{badgeIcon && (
<p className="text-xs text-muted-foreground">Selected: <span className="font-medium">{badgeIcon}</span></p>
)}
</div>
{/* Image upload */}
<div className="space-y-1.5">
<Label>Image <span className="text-muted-foreground text-xs">(overrides icon)</span></Label>
<BadgePicker
currentAsset={clearBadge ? null : (category?.badgeAsset ?? null)}
selectedAsset={selectedAsset}
onSelect={(a) => { setSelectedAsset(a); setClearBadge(false); }}
onClear={() => { setSelectedAsset(null); setClearBadge(true); }}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="badge_label">Badge Label</Label>
<Input
id="badge_label"
value={badgeLabel}
onChange={(e) => setBadgeLabel(e.target.value)}
placeholder="e.g. Premium Member"
/>
</div>
</SectionCard>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" onClick={handleSave} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
{isAdd ? "Create Category" : "Save Changes"}
</Button>
</div>
</div>
</div>
</div>
</section>
);
}
function Wrapper({ isAdd }) {
return (
<AssetsProvider>
<AdminTierCategoriesProvider>
<EditTierCategoryInner isAdd={isAdd} />
</AdminTierCategoriesProvider>
</AssetsProvider>
);
}
export function AddTierCategory() { return <Wrapper isAdd={true} />; }
export function EditTierCategory() { return <Wrapper isAdd={false} />; }