mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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} />; }
|
||||
Reference in New Issue
Block a user