award badges

This commit is contained in:
yohlo
2025-10-01 13:42:09 -05:00
parent 654041b6b6
commit 6224404aa9
7 changed files with 173 additions and 1 deletions

View File

@@ -33,6 +33,7 @@ import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_aut
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
@@ -161,6 +162,11 @@ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
path: '/preview',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminBadgesRoute = AuthedAdminBadgesRouteImport.update({
id: '/badges',
path: '/badges',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIndexRoute =
AuthedAdminTournamentsIndexRouteImport.update({
id: '/tournaments/',
@@ -206,6 +212,7 @@ export interface FileRoutesByFullPath {
'/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
@@ -236,6 +243,7 @@ export interface FileRoutesByTo {
'/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
@@ -269,6 +277,7 @@ export interface FileRoutesById {
'/_authed/settings': typeof AuthedSettingsRoute
'/_authed/stats': typeof AuthedStatsRoute
'/_authed/': typeof AuthedIndexRoute
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
@@ -302,6 +311,7 @@ export interface FileRouteTypes {
| '/settings'
| '/stats'
| '/'
| '/admin/badges'
| '/admin/preview'
| '/profile/$playerId'
| '/teams/$teamId'
@@ -332,6 +342,7 @@ export interface FileRouteTypes {
| '/settings'
| '/stats'
| '/'
| '/admin/badges'
| '/admin/preview'
| '/profile/$playerId'
| '/teams/$teamId'
@@ -364,6 +375,7 @@ export interface FileRouteTypes {
| '/_authed/settings'
| '/_authed/stats'
| '/_authed/'
| '/_authed/admin/badges'
| '/_authed/admin/preview'
| '/_authed/profile/$playerId'
| '/_authed/teams/$teamId'
@@ -576,6 +588,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/badges': {
id: '/_authed/admin/badges'
path: '/badges'
fullPath: '/admin/badges'
preLoaderRoute: typeof AuthedAdminBadgesRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/': {
id: '/_authed/admin/tournaments/'
path: '/tournaments'
@@ -622,6 +641,7 @@ declare module '@tanstack/react-router' {
}
interface AuthedAdminRouteChildren {
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
@@ -631,6 +651,7 @@ interface AuthedAdminRouteChildren {
}
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import AwardBadges from "@/features/admin/components/award-badges";
export const Route = createFileRoute("/_authed/admin/badges")({
component: RouteComponent,
});
function RouteComponent() {
return <AwardBadges />;
}

View File

@@ -5,6 +5,7 @@ import {
TreeStructureIcon,
TrophyIcon,
MedalIcon,
CrownIcon,
} from "@phosphor-icons/react";
import ListButton from "@/components/list-button";
import { migrateBadgeProgress } from "@/features/badges/server";
@@ -28,6 +29,11 @@ const AdminPage = () => {
Icon={TrophyIcon}
to="/admin/tournaments"
/>
<ListLink
label="Award Badges"
Icon={CrownIcon}
to="/admin/badges"
/>
<ListButton
label="Migrate Badge Progress"
Icon={MedalIcon}

View File

@@ -0,0 +1,99 @@
import { useState } from "react";
import { Box, Card, Text, Select, Button, Group, Stack } from "@mantine/core";
import { awardManualBadge } from "@/features/badges/server";
import { useAllBadges } from "@/features/badges/queries";
import toast from "@/lib/sonner";
import { usePlayers } from "@/features/players/queries";
const AwardBadges = () => {
const { data: players } = usePlayers();
const { data: allBadges } = useAllBadges();
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
const [isAwarding, setIsAwarding] = useState(false);
const manualBadges = allBadges.filter((badge) => badge.type === "manual");
const handleAwardBadge = async () => {
if (!selectedPlayerId || !selectedBadgeId) return;
setIsAwarding(true);
try {
await awardManualBadge({
data: {
playerId: selectedPlayerId,
badgeId: selectedBadgeId,
},
});
toast.success("Badge awarded successfully");
setSelectedPlayerId(null);
setSelectedBadgeId(null);
} catch (error) {
toast.error("Failed to award badge");
} finally {
setIsAwarding(false);
}
};
const playerOptions = players.map((player) => ({
value: player.id,
label: `${player.first_name} ${player.last_name}`,
}));
const badgeOptions = manualBadges.map((badge) => ({
value: badge.id,
label: badge.name,
}));
return (
<Box p="md">
<Card withBorder radius="md" p="md">
<Stack gap="md">
<Box>
<Text size="lg" fw={600} mb="xs">
Award Manual Badge
</Text>
<Text size="sm" c="dimmed">
Select a player and a manual badge to award
</Text>
</Box>
<Select
label="Player"
placeholder="Select a player"
data={playerOptions}
value={selectedPlayerId}
onChange={setSelectedPlayerId}
searchable
clearable
/>
<Select
label="Badge"
placeholder="Select a badge"
data={badgeOptions}
value={selectedBadgeId}
onChange={setSelectedBadgeId}
searchable
clearable
/>
<Group justify="flex-end">
<Button
onClick={handleAwardBadge}
disabled={!selectedPlayerId || !selectedBadgeId}
loading={isAwarding}
>
Award Badge
</Button>
</Group>
</Stack>
</Card>
</Box>
);
};
export default AwardBadges;

View File

@@ -86,7 +86,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
}
return (
<Box mb="lg">
<Box px="md" mb="lg">
<Card
withBorder
radius="md"

View File

@@ -22,3 +22,13 @@ export const getAllBadges = createServerFn()
.handler(async () =>
toServerResult(() => pbAdmin.listBadges())
);
export const awardManualBadge = createServerFn()
.inputValidator(z.object({
playerId: z.string(),
badgeId: z.string(),
}))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult(() => pbAdmin.awardManualBadge(data.playerId, data.badgeId))
);

View File

@@ -351,6 +351,32 @@ export function createBadgesService(pb: PocketBase) {
);
},
async awardManualBadge(playerId: string, badgeId: string): Promise<BadgeProgress> {
// Get or create badge progress record
const existingProgress = await pb.collection("badge_progress").getFirstListItem(
`player = "${playerId}" && badge = "${badgeId}"`,
{ expand: 'badge' }
).catch(() => null);
if (existingProgress) {
// Update existing progress to mark as earned
const updated = await pb.collection("badge_progress").update(existingProgress.id, {
progress: 1,
earned: true,
}, { expand: 'badge' });
return transformBadgeProgress(updated);
}
// Create new progress record
const created = await pb.collection("badge_progress").create({
badge: badgeId,
player: playerId,
progress: 1,
earned: true,
}, { expand: 'badge' });
return transformBadgeProgress(created);
},
async migrateBadgeProgress(): Promise<{
success: boolean;
playersProcessed: number;