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 AdminFoundationManager from "@/components/admin/AdminFoundationManager";
|
||||
import AdminNexusManager from "@/components/admin/AdminNexusManager";
|
||||
import AdminTierBadgeManager from "@/components/admin/AdminTierBadgeManager";
|
||||
import { changelogEntries } from "@/pages/Changelog";
|
||||
import { blogSeedPosts } from "@/data/blogSeed";
|
||||
import {
|
||||
|
|
@ -391,6 +392,7 @@ export default function Admin() {
|
|||
<TabsTrigger value="ethos">Ethos</TabsTrigger>
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
<TabsTrigger value="operations">Operations</TabsTrigger>
|
||||
<TabsTrigger value="tiers-badges">Tiers & Badges</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
|
|
@ -787,6 +789,13 @@ export default function Admin() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tiers-badges" className="space-y-6">
|
||||
<AdminTierBadgeManager
|
||||
profiles={managedProfiles}
|
||||
onProfilesChange={loadProfiles}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import LoadingScreen from "@/components/LoadingScreen";
|
|||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
aethexAchievementService,
|
||||
aethexBadgeService,
|
||||
aethexTierService,
|
||||
type AethexAchievement,
|
||||
type AethexUserBadge,
|
||||
} from "@/lib/aethex-database-adapter";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -31,6 +34,9 @@ import {
|
|||
Code2,
|
||||
Globe,
|
||||
Award,
|
||||
Star,
|
||||
Crown,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ProfileStat {
|
||||
|
|
@ -65,6 +71,9 @@ const Profile = () => {
|
|||
const { user, profile, loading: authLoading } = useAuth();
|
||||
const [achievements, setAchievements] = useState<AethexAchievement[]>([]);
|
||||
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 passportHref = `/passport/${encodeURIComponent(username)}`;
|
||||
|
|
@ -96,6 +105,27 @@ const Profile = () => {
|
|||
loadAchievements().catch(() => undefined);
|
||||
}, [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 level = Math.max(1, Number(profile?.level ?? 1));
|
||||
const totalXp = Math.max(0, Number(profile?.total_xp ?? 0));
|
||||
|
|
@ -289,6 +319,124 @@ const Profile = () => {
|
|||
</CardContent>
|
||||
</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">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-white">
|
||||
|
|
|
|||
Loading…
Reference in a new issue