Add achievement management component
cgen-b1fa3d25ad1c483b968399e75a771ba6
This commit is contained in:
parent
2731d95ae7
commit
afaef5b60a
1 changed files with 307 additions and 0 deletions
307
client/components/admin/AdminAchievementManager.tsx
Normal file
307
client/components/admin/AdminAchievementManager.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { aethexToast } from "@/lib/aethex-toast";
|
||||
import {
|
||||
aethexAchievementService,
|
||||
type AethexAchievement,
|
||||
type AethexUserProfile,
|
||||
} from "@/lib/aethex-database-adapter";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { Award, Gift, Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
interface AdminAchievementManagerProps {
|
||||
targetUser: AethexUserProfile | null;
|
||||
}
|
||||
|
||||
const AdminAchievementManager = ({
|
||||
targetUser,
|
||||
}: AdminAchievementManagerProps) => {
|
||||
const [achievements, setAchievements] = useState<AethexAchievement[]>([]);
|
||||
const [userAchievements, setUserAchievements] = useState<AethexAchievement[]>([]);
|
||||
const [selectedAchievementId, setSelectedAchievementId] = useState<string>("");
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingUserAchievements, setLoadingUserAchievements] = useState(false);
|
||||
const [awarding, setAwarding] = useState(false);
|
||||
const [activatingRewards, setActivatingRewards] = useState(false);
|
||||
|
||||
const loadAchievements = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
try {
|
||||
const list = await aethexAchievementService.getAllAchievements();
|
||||
setAchievements(list);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load achievements", error);
|
||||
setAchievements([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserAchievements = useCallback(
|
||||
async (userId: string) => {
|
||||
setLoadingUserAchievements(true);
|
||||
try {
|
||||
const list = await aethexAchievementService.getUserAchievements(userId);
|
||||
setUserAchievements(list);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load user achievements", error);
|
||||
setUserAchievements([]);
|
||||
} finally {
|
||||
setLoadingUserAchievements(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadAchievements().catch(() => undefined);
|
||||
}, [loadAchievements]);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetUser?.id) {
|
||||
loadUserAchievements(targetUser.id).catch(() => undefined);
|
||||
} else {
|
||||
setUserAchievements([]);
|
||||
}
|
||||
}, [targetUser?.id, loadUserAchievements]);
|
||||
|
||||
const selectedAchievement = useMemo(
|
||||
() => achievements.find((achievement) => achievement.id === selectedAchievementId) ?? null,
|
||||
[achievements, selectedAchievementId],
|
||||
);
|
||||
|
||||
const awardAchievement = async () => {
|
||||
if (!targetUser?.id || !selectedAchievementId) {
|
||||
aethexToast.error({
|
||||
title: "Select achievement",
|
||||
description: "Choose an achievement and member before awarding.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAwarding(true);
|
||||
try {
|
||||
await aethexAchievementService.awardAchievement(
|
||||
targetUser.id,
|
||||
selectedAchievementId,
|
||||
);
|
||||
aethexToast.success({
|
||||
title: "Achievement awarded",
|
||||
description: `${selectedAchievement?.name ?? "Achievement"} granted to ${targetUser.full_name ?? targetUser.email ?? "member"}.`,
|
||||
});
|
||||
await loadUserAchievements(targetUser.id);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to award achievement", error);
|
||||
aethexToast.error({
|
||||
title: "Award failed",
|
||||
description: error?.message || "Supabase rejected the award operation.",
|
||||
});
|
||||
} finally {
|
||||
setAwarding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activateRewards = async () => {
|
||||
if (!targetUser) {
|
||||
aethexToast.error({
|
||||
title: "Select member",
|
||||
description: "Choose a member before running rewards automation.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setActivatingRewards(true);
|
||||
try {
|
||||
const result = await aethexAchievementService.activateCommunityRewards({
|
||||
email: targetUser.email ?? undefined,
|
||||
username: targetUser.username ?? undefined,
|
||||
});
|
||||
if (!result) {
|
||||
aethexToast.error({
|
||||
title: "Activation failed",
|
||||
description: "No rewards were activated. Check server logs for details.",
|
||||
});
|
||||
} else {
|
||||
const awarded = result.awardedAchievementIds?.length ?? 0;
|
||||
aethexToast.success({
|
||||
title: "Rewards activated",
|
||||
description:
|
||||
awarded > 0
|
||||
? `${awarded} achievement${awarded === 1 ? "" : "s"} added for ${targetUser.full_name ?? targetUser.email ?? "member"}.`
|
||||
: "Rewards automation completed with no new awards.",
|
||||
});
|
||||
if (targetUser.id) {
|
||||
await loadUserAchievements(targetUser.id);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to activate rewards", error);
|
||||
aethexToast.error({
|
||||
title: "Automation failed",
|
||||
description: error?.message || "Could not trigger rewards function.",
|
||||
});
|
||||
} finally {
|
||||
setActivatingRewards(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-card/60 border-border/40 backdrop-blur">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Award className="h-5 w-5 text-purple-300" />
|
||||
Achievement control
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Grant rewards, run automations, and inspect achievement history.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Target member</p>
|
||||
{targetUser ? (
|
||||
<div className="rounded border border-border/40 bg-background/40 p-3">
|
||||
<p className="font-medium text-foreground">
|
||||
{targetUser.full_name ?? targetUser.username ?? targetUser.email ?? "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{targetUser.email ?? "No email on record"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a member from the directory to enable rewards management.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Choose achievement</p>
|
||||
<Select
|
||||
value={selectedAchievementId}
|
||||
onValueChange={setSelectedAchievementId}
|
||||
disabled={!targetUser || loadingList}
|
||||
>
|
||||
<SelectTrigger className="bg-background/60">
|
||||
<SelectValue placeholder={loadingList ? "Loading achievements…" : "Select achievement"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{achievements.map((achievement) => (
|
||||
<SelectItem key={achievement.id} value={achievement.id}>
|
||||
{achievement.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={awardAchievement}
|
||||
disabled={awarding || !targetUser}
|
||||
>
|
||||
{awarding ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Gift className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Award achievement
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={activateRewards}
|
||||
disabled={activatingRewards || !targetUser}
|
||||
>
|
||||
{activatingRewards ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Run rewards automation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-foreground">Achievement history</p>
|
||||
{loadingUserAchievements ? (
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading…
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{targetUser ? (
|
||||
<ScrollArea className="h-[220px] rounded border border-border/40 bg-background/40 p-3">
|
||||
{userAchievements.length ? (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{userAchievements.map((achievement) => (
|
||||
<li key={achievement.id} className="flex items-start justify-between gap-3 rounded border border-border/30 bg-background/40 p-3">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">{achievement.name}</p>
|
||||
{achievement.description ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{achievement.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Badge variant="outline" className="whitespace-nowrap text-xs">
|
||||
{achievement.xp_reward ?? 0} XP
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No achievements recorded yet.
|
||||
</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a member to view and manage their achievements.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedAchievement ? (
|
||||
<div className="rounded border border-border/40 bg-background/40 p-3 text-xs text-muted-foreground">
|
||||
<p className="mb-1 font-medium text-foreground">
|
||||
{selectedAchievement.name}
|
||||
</p>
|
||||
{selectedAchievement.description ? (
|
||||
<p className="mb-1">{selectedAchievement.description}</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2 text-[11px] uppercase tracking-wide">
|
||||
<Badge variant="outline">{selectedAchievement.xp_reward ?? 0} XP</Badge>
|
||||
<Badge variant="outline">ID: {selectedAchievement.id}</Badge>
|
||||
<Badge variant="outline">
|
||||
Created {formatDistanceToNowStrict(new Date(selectedAchievement.created_at), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminAchievementManager;
|
||||
Loading…
Reference in a new issue