mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
402 lines
20 KiB
React
402 lines
20 KiB
React
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,
|
|
Diamond, Swords, Mountain, Sun, Globe, Compass, Flag, GraduationCap,
|
|
Key, Fingerprint, Eye, Crosshair, Infinity, TrendingUp, Triangle, Octagon,
|
|
Circle, Disc, Feather, Snowflake, Leaf, TreePine, Gift, Heart,
|
|
HeartHandshake, Bird, BadgePlus, BadgePercent, Sigma, BookOpen,
|
|
Milestone, Navigation, Sunrise,
|
|
} from "lucide-react";
|
|
import * as LucideIcons from "lucide-react";
|
|
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
export const BADGE_ICON_OPTIONS = [
|
|
// Prestige / rank
|
|
{ name: "ShieldCheck", icon: ShieldCheck },
|
|
{ name: "Shield", icon: Shield },
|
|
{ name: "BadgeCheck", icon: BadgeCheck },
|
|
{ name: "BadgePlus", icon: BadgePlus },
|
|
{ name: "BadgePercent", icon: BadgePercent },
|
|
{ name: "Crown", icon: Crown },
|
|
{ name: "Star", icon: Star },
|
|
{ name: "Sparkles", icon: Sparkles },
|
|
{ name: "Diamond", icon: Diamond },
|
|
{ name: "Gem", icon: Gem },
|
|
// Awards
|
|
{ name: "Trophy", icon: Trophy },
|
|
{ name: "Medal", icon: Medal },
|
|
{ name: "Award", icon: Award },
|
|
{ name: "Gift", icon: Gift },
|
|
{ name: "Milestone", icon: Milestone },
|
|
// Power / energy
|
|
{ name: "Flame", icon: Flame },
|
|
{ name: "Zap", icon: Zap },
|
|
{ name: "Rocket", icon: Rocket },
|
|
{ name: "Sun", icon: Sun },
|
|
{ name: "Sunrise", icon: Sunrise },
|
|
// Action / skill
|
|
{ name: "Swords", icon: Swords },
|
|
{ name: "Target", icon: Target },
|
|
{ name: "Crosshair", icon: Crosshair },
|
|
{ name: "TrendingUp", icon: TrendingUp },
|
|
{ name: "Sigma", icon: Sigma },
|
|
// Navigation / scope
|
|
{ name: "Compass", icon: Compass },
|
|
{ name: "Navigation", icon: Navigation },
|
|
{ name: "Globe", icon: Globe },
|
|
{ name: "Mountain", icon: Mountain },
|
|
{ name: "Flag", icon: Flag },
|
|
// Knowledge
|
|
{ name: "GraduationCap", icon: GraduationCap },
|
|
{ name: "BookOpen", icon: BookOpen },
|
|
{ name: "Key", icon: Key },
|
|
{ name: "Fingerprint", icon: Fingerprint },
|
|
{ name: "Eye", icon: Eye },
|
|
// Nature
|
|
{ name: "Leaf", icon: Leaf },
|
|
{ name: "TreePine", icon: TreePine },
|
|
{ name: "Snowflake", icon: Snowflake },
|
|
{ name: "Bird", icon: Bird },
|
|
{ name: "Feather", icon: Feather },
|
|
// Heart
|
|
{ name: "Heart", icon: Heart },
|
|
{ name: "HeartHandshake",icon: HeartHandshake },
|
|
{ name: "Infinity", icon: Infinity },
|
|
// Shapes
|
|
{ name: "Hexagon", icon: Hexagon },
|
|
{ name: "Octagon", icon: Octagon },
|
|
{ name: "Triangle", icon: Triangle },
|
|
{ name: "Circle", icon: Circle },
|
|
{ name: "Disc", icon: Disc },
|
|
{ name: "CircleDot", icon: CircleDot },
|
|
{ name: "Layers", icon: Layers },
|
|
];
|
|
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("/admin/tiers/categories")}>
|
|
<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;
|
|
const bg = selected ? shadeColor(opt.swatch, -20) : opt.swatch;
|
|
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 transition-all ${selected ? "scale-105 shadow-md" : "opacity-80 hover:opacity-100"}`}
|
|
style={{ backgroundColor: bg, color: getContrastText(bg, opt.key) }}
|
|
>
|
|
{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("/admin/tiers/categories")} 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} />; }
|