Add tier and badge management for users
Introduces a new AdminTierBadgeManager component for managing user tiers and badges, and integrates tier/badge display into the user profile page. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 8a67ec83-78f3-477c-b7a9-0beed9395db5 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/MdI1YXa Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
63b94f2178
commit
d7dc9d1066
3 changed files with 610 additions and 0 deletions
453
client/components/admin/AdminTierBadgeManager.tsx
Normal file
453
client/components/admin/AdminTierBadgeManager.tsx
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
aethexUserService,
|
||||||
|
aethexBadgeService,
|
||||||
|
aethexTierService,
|
||||||
|
type AethexUserProfile,
|
||||||
|
type AethexBadge,
|
||||||
|
type AethexUserBadge,
|
||||||
|
} from "@/lib/aethex-database-adapter";
|
||||||
|
import { aethexToast } from "@/lib/aethex-toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
Crown,
|
||||||
|
Star,
|
||||||
|
Sparkles,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface AdminTierBadgeManagerProps {
|
||||||
|
profiles?: AethexUserProfile[];
|
||||||
|
onProfilesChange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminTierBadgeManager({
|
||||||
|
profiles: externalProfiles,
|
||||||
|
onProfilesChange,
|
||||||
|
}: AdminTierBadgeManagerProps) {
|
||||||
|
const [profiles, setProfiles] = useState<AethexUserProfile[]>([]);
|
||||||
|
const [allBadges, setAllBadges] = useState<AethexBadge[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
|
const [selectedUserBadges, setSelectedUserBadges] = useState<AethexUserBadge[]>([]);
|
||||||
|
const [selectedUserTier, setSelectedUserTier] = useState<"free" | "pro" | "council">("free");
|
||||||
|
const [loadingUserData, setLoadingUserData] = useState(false);
|
||||||
|
const [updatingTier, setUpdatingTier] = useState(false);
|
||||||
|
const [updatingBadge, setUpdatingBadge] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [profileList, badgeList] = await Promise.all([
|
||||||
|
externalProfiles ? Promise.resolve(externalProfiles) : aethexUserService.listProfiles(200),
|
||||||
|
aethexBadgeService.getAllBadges(),
|
||||||
|
]);
|
||||||
|
setProfiles(profileList);
|
||||||
|
setAllBadges(badgeList);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to load data:", err);
|
||||||
|
aethexToast.error({ description: "Failed to load user data" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [externalProfiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const loadSelectedUserData = useCallback(async (userId: string) => {
|
||||||
|
setLoadingUserData(true);
|
||||||
|
try {
|
||||||
|
const [tier, badges] = await Promise.all([
|
||||||
|
aethexTierService.getUserTier(userId),
|
||||||
|
aethexBadgeService.getUserBadges(userId),
|
||||||
|
]);
|
||||||
|
setSelectedUserTier(tier);
|
||||||
|
setSelectedUserBadges(badges);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to load user tier/badges:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingUserData(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedUserId) {
|
||||||
|
loadSelectedUserData(selectedUserId);
|
||||||
|
}
|
||||||
|
}, [selectedUserId, loadSelectedUserData]);
|
||||||
|
|
||||||
|
const handleTierChange = async (newTier: "free" | "pro" | "council") => {
|
||||||
|
if (!selectedUserId) return;
|
||||||
|
setUpdatingTier(true);
|
||||||
|
try {
|
||||||
|
const success = await aethexTierService.setUserTier(selectedUserId, newTier);
|
||||||
|
if (success) {
|
||||||
|
setSelectedUserTier(newTier);
|
||||||
|
aethexToast.success({ description: `Tier updated to ${newTier.charAt(0).toUpperCase() + newTier.slice(1)}` });
|
||||||
|
onProfilesChange?.();
|
||||||
|
} else {
|
||||||
|
aethexToast.error({ description: "Failed to update tier" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to update tier:", err);
|
||||||
|
aethexToast.error({ description: "Failed to update tier" });
|
||||||
|
} finally {
|
||||||
|
setUpdatingTier(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAwardBadge = async (badgeSlug: string) => {
|
||||||
|
if (!selectedUserId) return;
|
||||||
|
setUpdatingBadge(badgeSlug);
|
||||||
|
try {
|
||||||
|
const success = await aethexBadgeService.awardBadge(selectedUserId, badgeSlug);
|
||||||
|
if (success) {
|
||||||
|
await loadSelectedUserData(selectedUserId);
|
||||||
|
aethexToast.success({ description: "Badge awarded successfully" });
|
||||||
|
} else {
|
||||||
|
aethexToast.error({ description: "Failed to award badge" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to award badge:", err);
|
||||||
|
aethexToast.error({ description: "Failed to award badge" });
|
||||||
|
} finally {
|
||||||
|
setUpdatingBadge(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeBadge = async (badgeSlug: string) => {
|
||||||
|
if (!selectedUserId) return;
|
||||||
|
setUpdatingBadge(badgeSlug);
|
||||||
|
try {
|
||||||
|
const success = await aethexBadgeService.revokeBadge(selectedUserId, badgeSlug);
|
||||||
|
if (success) {
|
||||||
|
await loadSelectedUserData(selectedUserId);
|
||||||
|
aethexToast.success({ description: "Badge revoked" });
|
||||||
|
} else {
|
||||||
|
aethexToast.error({ description: "Failed to revoke badge" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to revoke badge:", err);
|
||||||
|
aethexToast.error({ description: "Failed to revoke badge" });
|
||||||
|
} finally {
|
||||||
|
setUpdatingBadge(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredProfiles = profiles.filter((p) => {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
(p.username?.toLowerCase() || "").includes(query) ||
|
||||||
|
(p.full_name?.toLowerCase() || "").includes(query) ||
|
||||||
|
(p.email?.toLowerCase() || "").includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedProfile = profiles.find((p) => p.id === selectedUserId);
|
||||||
|
const userBadgeSlugs = selectedUserBadges.map((ub) => ub.badge?.slug).filter(Boolean);
|
||||||
|
const availableBadges = allBadges.filter((b) => !userBadgeSlugs.includes(b.slug));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-aethex-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_2fr]">
|
||||||
|
<Card className="border-border/40 bg-background/60">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-aethex-300" />
|
||||||
|
Select User
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Search and select a user to manage their tier and badges.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by username, name, or email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[400px] overflow-y-auto space-y-2">
|
||||||
|
{filteredProfiles.slice(0, 50).map((profile) => (
|
||||||
|
<button
|
||||||
|
key={profile.id}
|
||||||
|
onClick={() => setSelectedUserId(profile.id)}
|
||||||
|
className={`w-full flex items-center gap-3 rounded-lg border p-3 text-left transition ${
|
||||||
|
selectedUserId === profile.id
|
||||||
|
? "border-aethex-400 bg-aethex-500/10"
|
||||||
|
: "border-border/40 bg-background/50 hover:border-aethex-400/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={profile.avatar_url || undefined} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{(profile.username || profile.full_name || "U")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-foreground truncate">
|
||||||
|
{profile.username || profile.full_name || "Unknown"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{profile.email || profile.id.slice(0, 8)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filteredProfiles.length === 0 && (
|
||||||
|
<p className="text-center text-sm text-muted-foreground py-4">
|
||||||
|
No users found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{selectedProfile ? (
|
||||||
|
<>
|
||||||
|
<Card className="border-border/40 bg-background/60">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar className="h-12 w-12">
|
||||||
|
<AvatarImage src={selectedProfile.avatar_url || undefined} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{(selectedProfile.username || selectedProfile.full_name || "U")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<CardTitle>
|
||||||
|
{selectedProfile.username || selectedProfile.full_name || "Unknown User"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{selectedProfile.email || selectedProfile.id}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-foreground/80">
|
||||||
|
Subscription Tier
|
||||||
|
</h3>
|
||||||
|
{loadingUserData ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedUserTier === "council" ? (
|
||||||
|
<Crown className="h-5 w-5 text-amber-400" />
|
||||||
|
) : selectedUserTier === "pro" ? (
|
||||||
|
<Star className="h-5 w-5 text-purple-400" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-5 w-5 text-slate-400" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium capitalize">{selectedUserTier}</span>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={selectedUserTier}
|
||||||
|
onValueChange={(value: "free" | "pro" | "council") => handleTierChange(value)}
|
||||||
|
disabled={updatingTier}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Change tier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="free">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-slate-400" />
|
||||||
|
Free
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="pro">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Star className="h-4 w-4 text-purple-400" />
|
||||||
|
Pro
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="council">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Crown className="h-4 w-4 text-amber-400" />
|
||||||
|
Council
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{updatingTier && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border/40 bg-background/60">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Crown className="h-5 w-5 text-amber-300" />
|
||||||
|
User Badges
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage badges for this user. Some badges unlock AI personas.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{loadingUserData ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading badges...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-foreground/80">
|
||||||
|
Earned Badges ({selectedUserBadges.length})
|
||||||
|
</h4>
|
||||||
|
{selectedUserBadges.length > 0 ? (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{selectedUserBadges.map((ub) => (
|
||||||
|
<div
|
||||||
|
key={ub.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border/40 bg-background/50 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-aethex-500/30 to-neon-blue/30 text-sm">
|
||||||
|
{ub.badge?.icon || "🏆"}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-foreground text-sm truncate">
|
||||||
|
{ub.badge?.name || "Badge"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(ub.earned_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||||
|
onClick={() => ub.badge?.slug && handleRevokeBadge(ub.badge.slug)}
|
||||||
|
disabled={updatingBadge === ub.badge?.slug}
|
||||||
|
>
|
||||||
|
{updatingBadge === ub.badge?.slug ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No badges earned yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-foreground/80">
|
||||||
|
Award New Badge
|
||||||
|
</h4>
|
||||||
|
{availableBadges.length > 0 ? (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{availableBadges.map((badge) => (
|
||||||
|
<div
|
||||||
|
key={badge.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-dashed border-border/40 bg-background/30 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-500/20 text-sm">
|
||||||
|
{badge.icon || "🏅"}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-foreground text-sm truncate">
|
||||||
|
{badge.name}
|
||||||
|
</p>
|
||||||
|
{badge.unlocks_persona && (
|
||||||
|
<Badge variant="outline" className="text-xs border-green-500/60 text-green-300">
|
||||||
|
Unlocks AI
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0 text-green-400 hover:text-green-300 hover:bg-green-500/10"
|
||||||
|
onClick={() => handleAwardBadge(badge.slug)}
|
||||||
|
disabled={updatingBadge === badge.slug}
|
||||||
|
>
|
||||||
|
{updatingBadge === badge.slug ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{allBadges.length === 0
|
||||||
|
? "No badges defined in the system yet."
|
||||||
|
: "User has all available badges."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card className="border-border/40 bg-background/60">
|
||||||
|
<CardContent className="flex items-center justify-center py-16">
|
||||||
|
<div className="text-center">
|
||||||
|
<Shield className="h-12 w-12 text-muted-foreground/50 mx-auto mb-4" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Select a user from the list to manage their tier and badges.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ import AdminEthosVerification from "@/pages/admin/AdminEthosVerification";
|
||||||
import AdminGameForgeStudio from "@/components/admin/AdminGameForgeStudio";
|
import AdminGameForgeStudio from "@/components/admin/AdminGameForgeStudio";
|
||||||
import AdminFoundationManager from "@/components/admin/AdminFoundationManager";
|
import AdminFoundationManager from "@/components/admin/AdminFoundationManager";
|
||||||
import AdminNexusManager from "@/components/admin/AdminNexusManager";
|
import AdminNexusManager from "@/components/admin/AdminNexusManager";
|
||||||
|
import AdminTierBadgeManager from "@/components/admin/AdminTierBadgeManager";
|
||||||
import { changelogEntries } from "@/pages/Changelog";
|
import { changelogEntries } from "@/pages/Changelog";
|
||||||
import { blogSeedPosts } from "@/data/blogSeed";
|
import { blogSeedPosts } from "@/data/blogSeed";
|
||||||
import {
|
import {
|
||||||
|
|
@ -391,6 +392,7 @@ export default function Admin() {
|
||||||
<TabsTrigger value="ethos">Ethos</TabsTrigger>
|
<TabsTrigger value="ethos">Ethos</TabsTrigger>
|
||||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||||
<TabsTrigger value="operations">Operations</TabsTrigger>
|
<TabsTrigger value="operations">Operations</TabsTrigger>
|
||||||
|
<TabsTrigger value="tiers-badges">Tiers & Badges</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="space-y-6">
|
<TabsContent value="overview" className="space-y-6">
|
||||||
|
|
@ -787,6 +789,13 @@ export default function Admin() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tiers-badges" className="space-y-6">
|
||||||
|
<AdminTierBadgeManager
|
||||||
|
profiles={managedProfiles}
|
||||||
|
onProfilesChange={loadProfiles}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ import LoadingScreen from "@/components/LoadingScreen";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import {
|
import {
|
||||||
aethexAchievementService,
|
aethexAchievementService,
|
||||||
|
aethexBadgeService,
|
||||||
|
aethexTierService,
|
||||||
type AethexAchievement,
|
type AethexAchievement,
|
||||||
|
type AethexUserBadge,
|
||||||
} from "@/lib/aethex-database-adapter";
|
} from "@/lib/aethex-database-adapter";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -31,6 +34,9 @@ import {
|
||||||
Code2,
|
Code2,
|
||||||
Globe,
|
Globe,
|
||||||
Award,
|
Award,
|
||||||
|
Star,
|
||||||
|
Crown,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface ProfileStat {
|
interface ProfileStat {
|
||||||
|
|
@ -65,6 +71,9 @@ const Profile = () => {
|
||||||
const { user, profile, loading: authLoading } = useAuth();
|
const { user, profile, loading: authLoading } = useAuth();
|
||||||
const [achievements, setAchievements] = useState<AethexAchievement[]>([]);
|
const [achievements, setAchievements] = useState<AethexAchievement[]>([]);
|
||||||
const [loadingAchievements, setLoadingAchievements] = useState(false);
|
const [loadingAchievements, setLoadingAchievements] = useState(false);
|
||||||
|
const [userTier, setUserTier] = useState<"free" | "pro" | "council">("free");
|
||||||
|
const [userBadges, setUserBadges] = useState<AethexUserBadge[]>([]);
|
||||||
|
const [loadingTierBadges, setLoadingTierBadges] = useState(false);
|
||||||
|
|
||||||
const username = profile?.username || user?.email?.split("@")[0] || "creator";
|
const username = profile?.username || user?.email?.split("@")[0] || "creator";
|
||||||
const passportHref = `/passport/${encodeURIComponent(username)}`;
|
const passportHref = `/passport/${encodeURIComponent(username)}`;
|
||||||
|
|
@ -96,6 +105,27 @@ const Profile = () => {
|
||||||
loadAchievements().catch(() => undefined);
|
loadAchievements().catch(() => undefined);
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTierAndBadges = async () => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
setLoadingTierBadges(true);
|
||||||
|
try {
|
||||||
|
const [tier, badges] = await Promise.all([
|
||||||
|
aethexTierService.getUserTier(user.id),
|
||||||
|
aethexBadgeService.getUserBadges(user.id),
|
||||||
|
]);
|
||||||
|
setUserTier(tier);
|
||||||
|
setUserBadges(badges);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to load tier/badges for profile", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTierBadges(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTierAndBadges().catch(() => undefined);
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
const stats = useMemo<ProfileStat[]>(() => {
|
const stats = useMemo<ProfileStat[]>(() => {
|
||||||
const level = Math.max(1, Number(profile?.level ?? 1));
|
const level = Math.max(1, Number(profile?.level ?? 1));
|
||||||
const totalXp = Math.max(0, Number(profile?.total_xp ?? 0));
|
const totalXp = Math.max(0, Number(profile?.total_xp ?? 0));
|
||||||
|
|
@ -289,6 +319,124 @@ const Profile = () => {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* MEMBERSHIP TIER & BADGES */}
|
||||||
|
<Card className="border-border/40 bg-background/60 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-white">
|
||||||
|
<Crown className="h-5 w-5 text-amber-300" />
|
||||||
|
Membership & Badges
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your subscription tier and earned badges unlock AI personas and features.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Membership Tier */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-foreground/80">
|
||||||
|
Current Tier
|
||||||
|
</h3>
|
||||||
|
{loadingTierBadges ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-border/40 bg-background/50 p-4 text-sm text-muted-foreground">
|
||||||
|
<div className="h-3 w-3 animate-ping rounded-full bg-aethex-400/80" />
|
||||||
|
Loading membership info...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 rounded-lg border p-4 transition ${
|
||||||
|
userTier === "council"
|
||||||
|
? "border-amber-500/60 bg-gradient-to-r from-amber-500/20 to-orange-500/20"
|
||||||
|
: userTier === "pro"
|
||||||
|
? "border-purple-500/60 bg-gradient-to-r from-purple-500/20 to-indigo-500/20"
|
||||||
|
: "border-border/40 bg-background/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{userTier === "council" ? (
|
||||||
|
<Crown className="h-6 w-6 text-amber-400" />
|
||||||
|
) : userTier === "pro" ? (
|
||||||
|
<Star className="h-6 w-6 text-purple-400" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-6 w-6 text-slate-400" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white capitalize">
|
||||||
|
{userTier === "council"
|
||||||
|
? "Council Member"
|
||||||
|
: userTier === "pro"
|
||||||
|
? "Pro Member"
|
||||||
|
: "Free Tier"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{userTier === "council"
|
||||||
|
? "Full access to all AI personas"
|
||||||
|
: userTier === "pro"
|
||||||
|
? "Access to Pro-tier AI personas"
|
||||||
|
: "Basic AI persona access"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{userTier === "free" && (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600"
|
||||||
|
>
|
||||||
|
<Link to="/pricing">Upgrade</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Earned Badges */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-foreground/80">
|
||||||
|
Earned Badges
|
||||||
|
</h3>
|
||||||
|
{loadingTierBadges ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-border/40 bg-background/50 p-4 text-sm text-muted-foreground">
|
||||||
|
<div className="h-3 w-3 animate-ping rounded-full bg-aethex-400/80" />
|
||||||
|
Loading badges...
|
||||||
|
</div>
|
||||||
|
) : userBadges.length > 0 ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{userBadges.map((ub) => (
|
||||||
|
<div
|
||||||
|
key={ub.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border/40 bg-background/50 p-3 transition hover:border-aethex-400/60"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-aethex-500/30 to-neon-blue/30 text-lg">
|
||||||
|
{ub.badge?.icon || "🏆"}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-foreground truncate">
|
||||||
|
{ub.badge?.name || "Badge"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{ub.badge?.description || `Earned ${new Date(ub.earned_at).toLocaleDateString()}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{ub.badge?.unlocks_persona && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-green-500/60 text-green-300 text-xs shrink-0"
|
||||||
|
>
|
||||||
|
Unlocks AI
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded border border-dashed border-border/40 bg-background/40 p-4 text-sm text-muted-foreground">
|
||||||
|
Complete activities and challenges to earn badges that unlock exclusive features and AI personas.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="border-border/40 bg-background/60 backdrop-blur">
|
<Card className="border-border/40 bg-background/60 backdrop-blur">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-white">
|
<CardTitle className="flex items-center gap-2 text-white">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue