award badges
This commit is contained in:
@@ -33,6 +33,7 @@ import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_aut
|
|||||||
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
||||||
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
||||||
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
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 AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||||
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||||
@@ -161,6 +162,11 @@ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
|
|||||||
path: '/preview',
|
path: '/preview',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedAdminBadgesRoute = AuthedAdminBadgesRouteImport.update({
|
||||||
|
id: '/badges',
|
||||||
|
path: '/badges',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
const AuthedAdminTournamentsIndexRoute =
|
const AuthedAdminTournamentsIndexRoute =
|
||||||
AuthedAdminTournamentsIndexRouteImport.update({
|
AuthedAdminTournamentsIndexRouteImport.update({
|
||||||
id: '/tournaments/',
|
id: '/tournaments/',
|
||||||
@@ -206,6 +212,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/settings': typeof AuthedSettingsRoute
|
'/settings': typeof AuthedSettingsRoute
|
||||||
'/stats': typeof AuthedStatsRoute
|
'/stats': typeof AuthedStatsRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
|
'/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
@@ -236,6 +243,7 @@ export interface FileRoutesByTo {
|
|||||||
'/settings': typeof AuthedSettingsRoute
|
'/settings': typeof AuthedSettingsRoute
|
||||||
'/stats': typeof AuthedStatsRoute
|
'/stats': typeof AuthedStatsRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
|
'/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
@@ -269,6 +277,7 @@ export interface FileRoutesById {
|
|||||||
'/_authed/settings': typeof AuthedSettingsRoute
|
'/_authed/settings': typeof AuthedSettingsRoute
|
||||||
'/_authed/stats': typeof AuthedStatsRoute
|
'/_authed/stats': typeof AuthedStatsRoute
|
||||||
'/_authed/': typeof AuthedIndexRoute
|
'/_authed/': typeof AuthedIndexRoute
|
||||||
|
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
|
||||||
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
||||||
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
@@ -302,6 +311,7 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/stats'
|
| '/stats'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/admin/badges'
|
||||||
| '/admin/preview'
|
| '/admin/preview'
|
||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
@@ -332,6 +342,7 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/stats'
|
| '/stats'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/admin/badges'
|
||||||
| '/admin/preview'
|
| '/admin/preview'
|
||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
@@ -364,6 +375,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/settings'
|
| '/_authed/settings'
|
||||||
| '/_authed/stats'
|
| '/_authed/stats'
|
||||||
| '/_authed/'
|
| '/_authed/'
|
||||||
|
| '/_authed/admin/badges'
|
||||||
| '/_authed/admin/preview'
|
| '/_authed/admin/preview'
|
||||||
| '/_authed/profile/$playerId'
|
| '/_authed/profile/$playerId'
|
||||||
| '/_authed/teams/$teamId'
|
| '/_authed/teams/$teamId'
|
||||||
@@ -576,6 +588,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
|
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/admin/badges': {
|
||||||
|
id: '/_authed/admin/badges'
|
||||||
|
path: '/badges'
|
||||||
|
fullPath: '/admin/badges'
|
||||||
|
preLoaderRoute: typeof AuthedAdminBadgesRouteImport
|
||||||
|
parentRoute: typeof AuthedAdminRoute
|
||||||
|
}
|
||||||
'/_authed/admin/tournaments/': {
|
'/_authed/admin/tournaments/': {
|
||||||
id: '/_authed/admin/tournaments/'
|
id: '/_authed/admin/tournaments/'
|
||||||
path: '/tournaments'
|
path: '/tournaments'
|
||||||
@@ -622,6 +641,7 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AuthedAdminRouteChildren {
|
interface AuthedAdminRouteChildren {
|
||||||
|
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
|
||||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||||
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||||
@@ -631,6 +651,7 @@ interface AuthedAdminRouteChildren {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||||
|
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
|
||||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||||
|
|||||||
10
src/app/routes/_authed/admin/badges.tsx
Normal file
10
src/app/routes/_authed/admin/badges.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
TreeStructureIcon,
|
TreeStructureIcon,
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
MedalIcon,
|
MedalIcon,
|
||||||
|
CrownIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import ListButton from "@/components/list-button";
|
import ListButton from "@/components/list-button";
|
||||||
import { migrateBadgeProgress } from "@/features/badges/server";
|
import { migrateBadgeProgress } from "@/features/badges/server";
|
||||||
@@ -28,6 +29,11 @@ const AdminPage = () => {
|
|||||||
Icon={TrophyIcon}
|
Icon={TrophyIcon}
|
||||||
to="/admin/tournaments"
|
to="/admin/tournaments"
|
||||||
/>
|
/>
|
||||||
|
<ListLink
|
||||||
|
label="Award Badges"
|
||||||
|
Icon={CrownIcon}
|
||||||
|
to="/admin/badges"
|
||||||
|
/>
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Migrate Badge Progress"
|
label="Migrate Badge Progress"
|
||||||
Icon={MedalIcon}
|
Icon={MedalIcon}
|
||||||
|
|||||||
99
src/features/admin/components/award-badges.tsx
Normal file
99
src/features/admin/components/award-badges.tsx
Normal 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;
|
||||||
@@ -86,7 +86,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box mb="lg">
|
<Box px="md" mb="lg">
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -22,3 +22,13 @@ export const getAllBadges = createServerFn()
|
|||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult(() => pbAdmin.listBadges())
|
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))
|
||||||
|
);
|
||||||
|
|||||||
@@ -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<{
|
async migrateBadgeProgress(): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
playersProcessed: number;
|
playersProcessed: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user