diff --git a/pb_migrations/1759285520_deleted_player_badge_progress.js b/pb_migrations/1759285520_deleted_player_badge_progress.js
new file mode 100644
index 0000000..f469e81
--- /dev/null
+++ b/pb_migrations/1759285520_deleted_player_badge_progress.js
@@ -0,0 +1,106 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_badge_progress");
+
+ return app.delete(collection);
+}, (app) => {
+ const collection = new Collection({
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "cascadeDelete": true,
+ "collectionId": "pbc_3072146508",
+ "hidden": false,
+ "id": "relation_player",
+ "maxSelect": 1,
+ "minSelect": 1,
+ "name": "player_id",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "cascadeDelete": true,
+ "collectionId": "pbc_1340419796",
+ "hidden": false,
+ "id": "relation_badge",
+ "maxSelect": 1,
+ "minSelect": 1,
+ "name": "badge_id",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "hidden": false,
+ "id": "number_current",
+ "max": null,
+ "min": 0,
+ "name": "current_progress",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "bool_earned",
+ "name": "is_earned",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_badge_progress",
+ "indexes": [
+ "CREATE UNIQUE INDEX `idx_player_badge` ON `player_badge_progress` (`player_id`, `badge_id`)"
+ ],
+ "listRule": null,
+ "name": "player_badge_progress",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ return app.save(collection);
+})
diff --git a/pb_migrations/1759285544_updated_players.js b/pb_migrations/1759285544_updated_players.js
new file mode 100644
index 0000000..29769c3
--- /dev/null
+++ b/pb_migrations/1759285544_updated_players.js
@@ -0,0 +1,28 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_3072146508")
+
+ // remove field
+ collection.fields.removeById("relation2029409178")
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_3072146508")
+
+ // add field
+ collection.fields.addAt(5, new Field({
+ "cascadeDelete": false,
+ "collectionId": "pbc_1340419796",
+ "hidden": false,
+ "id": "relation2029409178",
+ "maxSelect": 999,
+ "minSelect": 0,
+ "name": "badges",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ }))
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1759285564_updated_players.js b/pb_migrations/1759285564_updated_players.js
new file mode 100644
index 0000000..52ac9ba
--- /dev/null
+++ b/pb_migrations/1759285564_updated_players.js
@@ -0,0 +1,28 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_3072146508")
+
+ // remove field
+ collection.fields.removeById("relation2813965191")
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_3072146508")
+
+ // add field
+ collection.fields.addAt(5, new Field({
+ "cascadeDelete": false,
+ "collectionId": "pbc_1340419796",
+ "hidden": false,
+ "id": "relation2813965191",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "featured_badge",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ }))
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1759285574_deleted_badges.js b/pb_migrations/1759285574_deleted_badges.js
new file mode 100644
index 0000000..ba2b038
--- /dev/null
+++ b/pb_migrations/1759285574_deleted_badges.js
@@ -0,0 +1,159 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1340419796");
+
+ return app.delete(collection);
+}, (app) => {
+ const collection = new Collection({
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text1579384326",
+ "max": 0,
+ "min": 0,
+ "name": "name",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text1843675174",
+ "max": 0,
+ "min": 0,
+ "name": "description",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "select4029814376",
+ "maxSelect": 1,
+ "name": "type",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "select",
+ "values": [
+ "tournament_participation",
+ "tournament_placement",
+ "performance",
+ "overtime",
+ "match_milestone"
+ ]
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_icon_key",
+ "max": 100,
+ "min": 0,
+ "name": "icon_key",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "json1578432567",
+ "maxSize": 2000000,
+ "name": "criteria",
+ "presentable": false,
+ "required": true,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "number_sort",
+ "max": null,
+ "min": 0,
+ "name": "sort_order",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "bool2847519203",
+ "name": "is_progressive",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ },
+ {
+ "hidden": false,
+ "id": "number2948571038",
+ "max": null,
+ "min": null,
+ "name": "progress_target",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_1340419796",
+ "indexes": [],
+ "listRule": null,
+ "name": "badges",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ return app.save(collection);
+})
diff --git a/pb_migrations/1759285803_created_badges.js b/pb_migrations/1759285803_created_badges.js
new file mode 100644
index 0000000..6578255
--- /dev/null
+++ b/pb_migrations/1759285803_created_badges.js
@@ -0,0 +1,133 @@
+///
+migrate((app) => {
+ const collection = new Collection({
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text1579384326",
+ "max": 0,
+ "min": 0,
+ "name": "name",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text2324736937",
+ "max": 0,
+ "min": 0,
+ "name": "key",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text1843675174",
+ "max": 0,
+ "min": 0,
+ "name": "description",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "json3055524737",
+ "maxSize": 0,
+ "name": "criteria",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "select2363381545",
+ "maxSelect": 1,
+ "name": "type",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "select",
+ "values": [
+ "manual",
+ "match",
+ "tournament"
+ ]
+ },
+ {
+ "hidden": false,
+ "id": "bool3646955747",
+ "name": "progressive",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_1340419796",
+ "indexes": [],
+ "listRule": null,
+ "name": "badges",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1340419796");
+
+ return app.delete(collection);
+})
diff --git a/pb_migrations/1759285923_created_badge_progress.js b/pb_migrations/1759285923_created_badge_progress.js
new file mode 100644
index 0000000..d645713
--- /dev/null
+++ b/pb_migrations/1759285923_created_badge_progress.js
@@ -0,0 +1,104 @@
+///
+migrate((app) => {
+ const collection = new Collection({
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": "pbc_1340419796",
+ "hidden": false,
+ "id": "relation4277159965",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "badge",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": "pbc_3072146508",
+ "hidden": false,
+ "id": "relation2551806565",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "player",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "hidden": false,
+ "id": "number570552902",
+ "max": null,
+ "min": null,
+ "name": "progress",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "bool2625885481",
+ "name": "earned",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_3342597247",
+ "indexes": [],
+ "listRule": null,
+ "name": "badge_progress",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_3342597247");
+
+ return app.delete(collection);
+})
diff --git a/pb_migrations/1759340868_updated_badges.js b/pb_migrations/1759340868_updated_badges.js
new file mode 100644
index 0000000..db4a6ca
--- /dev/null
+++ b/pb_migrations/1759340868_updated_badges.js
@@ -0,0 +1,27 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1340419796")
+
+ // add field
+ collection.fields.addAt(7, new Field({
+ "hidden": false,
+ "id": "number4113142680",
+ "max": null,
+ "min": null,
+ "name": "order",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ }))
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1340419796")
+
+ // remove field
+ collection.fields.removeById("number4113142680")
+
+ return app.save(collection)
+})
diff --git a/src/components/list-button.tsx b/src/components/list-button.tsx
index 02f5ac2..07cafe5 100644
--- a/src/components/list-button.tsx
+++ b/src/components/list-button.tsx
@@ -1,22 +1,33 @@
-import { Divider, Group, Text, UnstyledButton } from "@mantine/core";
+import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
interface ListButtonProps {
label: string;
Icon: Icon;
onClick: () => void;
+ loading?: boolean;
}
-const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
+const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => {
return (
<>
-
+
{label}
-
+ {loading ? (
+
+ ) : (
+
+ )}
diff --git a/src/features/admin/components/admin-page.tsx b/src/features/admin/components/admin-page.tsx
index c4f4b82..d8ad538 100644
--- a/src/features/admin/components/admin-page.tsx
+++ b/src/features/admin/components/admin-page.tsx
@@ -4,10 +4,23 @@ import {
DatabaseIcon,
TreeStructureIcon,
TrophyIcon,
+ MedalIcon,
} from "@phosphor-icons/react";
import ListButton from "@/components/list-button";
+import { migrateBadgeProgress } from "@/features/badges/server";
+import { useState } from "react";
const AdminPage = () => {
+ const [isMigrating, setIsMigrating] = useState(false);
+
+ const handleMigrateBadges = async () => {
+ if (isMigrating) return;
+
+ setIsMigrating(true);
+ await migrateBadgeProgress();
+ setIsMigrating(false);
+ };
+
return (
{
Icon={TrophyIcon}
to="/admin/tournaments"
/>
+
{
+ return (
+
+
+
+
+ Badges
+
+
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => (
+
+ ))}
+
+
+
+ );
+};
+
+export default BadgeShowcaseSkeleton;
diff --git a/src/features/badges/components/badge-showcase.tsx b/src/features/badges/components/badge-showcase.tsx
new file mode 100644
index 0000000..3a9bde9
--- /dev/null
+++ b/src/features/badges/components/badge-showcase.tsx
@@ -0,0 +1,189 @@
+import { Box, Text, Tooltip, Card } from "@mantine/core";
+import { usePlayerBadges, useAllBadges } from "../queries";
+import { useAuth } from "@/contexts/auth-context";
+import { Badge, BadgeProgress } from "../types";
+import { useMemo } from "react";
+
+interface BadgeShowcaseProps {
+ playerId: string;
+}
+
+interface BadgeDisplay {
+ badge: Badge;
+ progress?: BadgeProgress;
+ earned: boolean;
+ progressText: string;
+}
+
+const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
+ const { user } = useAuth();
+ const { data: badgeProgress } = usePlayerBadges(playerId);
+ const { data: allBadges } = useAllBadges();
+
+ const isCurrentUser = user?.id === playerId;
+
+ const badgesToDisplay = useMemo(() => {
+ const displays: BadgeDisplay[] = [];
+
+ if (isCurrentUser) {
+ for (const badge of allBadges) {
+ const progress = badgeProgress.find(bp => bp.badge.id === badge.id);
+ const earned = progress?.earned || false;
+
+ if (badge.type === 'manual' && !earned) {
+ continue;
+ }
+
+ let progressText = "";
+ if (progress) {
+ const target = getTargetProgress(badge);
+ progressText = `${progress.progress} / ${target}`;
+ } else {
+ const target = getTargetProgress(badge);
+ progressText = `0 / ${target}`;
+ }
+
+ displays.push({
+ badge,
+ progress,
+ earned,
+ progressText,
+ });
+ }
+
+ displays.sort((a, b) => {
+ if (a.earned && !b.earned) return -1;
+ if (!a.earned && b.earned) return 1;
+ return a.badge.order - b.badge.order;
+ });
+ } else {
+ const earnedProgress = badgeProgress.filter(bp => bp.earned);
+ for (const progress of earnedProgress) {
+ const badge: Badge = {
+ ...progress.badge,
+ criteria: {},
+ created: progress.created,
+ updated: progress.updated,
+ };
+
+ const target = getTargetProgress(badge);
+ displays.push({
+ badge,
+ progress,
+ earned: true,
+ progressText: `${progress.progress} / ${target}`,
+ });
+ }
+
+ displays.sort((a, b) => a.badge.order - b.badge.order);
+ }
+
+ return displays;
+ }, [allBadges, badgeProgress, isCurrentUser]);
+
+ if (badgesToDisplay.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ Badges
+
+
+
+
+ {badgesToDisplay.map((display) => (
+
+
+ {display.badge.name}
+
+
+ {display.badge.description}
+
+ {isCurrentUser && (
+
+ Progress: {display.progressText}
+
+ )}
+
+ }
+ multiline
+ w={220}
+ >
+ ({
+ opacity: display.earned ? 1 : 0.35,
+ cursor: "pointer",
+ transition: 'all 0.2s ease',
+ minHeight: 70,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderStyle: display.earned ? 'solid' : 'dashed',
+ ':hover': {
+ transform: display.earned ? 'translateY(-2px)' : 'none',
+ boxShadow: display.earned ? theme.shadows.sm : undefined,
+ },
+ })}
+ >
+
+ {display.badge.name}
+
+
+
+ ))}
+
+
+
+ );
+};
+
+function getTargetProgress(badge: Badge): number {
+ const criteria = badge.criteria;
+ return (
+ criteria.matches_played ||
+ criteria.tournament_wins ||
+ criteria.tournaments_attended ||
+ criteria.overtime_matches ||
+ criteria.overtime_wins ||
+ criteria.consecutive_wins ||
+ 1
+ );
+}
+
+export default BadgeShowcase;
diff --git a/src/features/badges/queries.ts b/src/features/badges/queries.ts
new file mode 100644
index 0000000..6428f9d
--- /dev/null
+++ b/src/features/badges/queries.ts
@@ -0,0 +1,24 @@
+import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
+import { getPlayerBadges, getAllBadges } from "./server";
+
+export const badgeKeys = {
+ playerBadges: (playerId: string) => ['badges', 'player', playerId],
+ allBadges: () => ['badges', 'all'],
+};
+
+export const badgeQueries = {
+ playerBadges: (playerId: string) => ({
+ queryKey: badgeKeys.playerBadges(playerId),
+ queryFn: async () => await getPlayerBadges({ data: playerId })
+ }),
+ allBadges: () => ({
+ queryKey: badgeKeys.allBadges(),
+ queryFn: async () => await getAllBadges()
+ }),
+};
+
+export const usePlayerBadges = (playerId: string) =>
+ useServerSuspenseQuery(badgeQueries.playerBadges(playerId));
+
+export const useAllBadges = () =>
+ useServerSuspenseQuery(badgeQueries.allBadges());
diff --git a/src/features/badges/server.ts b/src/features/badges/server.ts
index 9306d85..973c5f2 100644
--- a/src/features/badges/server.ts
+++ b/src/features/badges/server.ts
@@ -1,4 +1,24 @@
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
-import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
+import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
+import { pbAdmin } from "@/lib/pocketbase/client";
+import { z } from "zod";
+export const getPlayerBadges = createServerFn()
+ .inputValidator(z.string())
+ .middleware([superTokensFunctionMiddleware])
+ .handler(async ({ data: playerId }) =>
+ toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId))
+ );
+
+export const migrateBadgeProgress = createServerFn()
+ .middleware([superTokensAdminFunctionMiddleware])
+ .handler(async () =>
+ toServerResult(() => pbAdmin.migrateBadgeProgress())
+ );
+
+export const getAllBadges = createServerFn()
+ .middleware([superTokensFunctionMiddleware])
+ .handler(async () =>
+ toServerResult(() => pbAdmin.listBadges())
+ );
diff --git a/src/features/badges/types.ts b/src/features/badges/types.ts
new file mode 100644
index 0000000..a71cf6c
--- /dev/null
+++ b/src/features/badges/types.ts
@@ -0,0 +1,25 @@
+export interface BadgeInfo {
+ id: string;
+ name: string;
+ key: string;
+ description: string;
+ type: "manual" | "match" | "tournament";
+ progressive: boolean;
+ order: number;
+}
+
+export interface Badge extends BadgeInfo {
+ criteria: Record;
+ created: string;
+ updated: string;
+}
+
+export interface BadgeProgress {
+ id: string;
+ badge: BadgeInfo;
+ player: string;
+ progress: number;
+ earned: boolean;
+ created: string;
+ updated: string;
+}
diff --git a/src/features/players/components/profile/index.tsx b/src/features/players/components/profile/index.tsx
index 15acb78..9ae7a9b 100644
--- a/src/features/players/components/profile/index.tsx
+++ b/src/features/players/components/profile/index.tsx
@@ -1,10 +1,13 @@
import { Box } from "@mantine/core";
+import { Suspense } from "react";
import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list";
+import BadgeShowcase from "@/features/badges/components/badge-showcase";
+import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
interface ProfileProps {
id: string;
@@ -18,7 +21,14 @@ const Profile = ({ id }: ProfileProps) => {
const tabs = [
{
label: "Overview",
- content: ,
+ content: (
+ <>
+ }>
+
+
+
+ >
+ ),
},
{
label: "Matches",
diff --git a/src/lib/pocketbase/client.ts b/src/lib/pocketbase/client.ts
index 9ebee61..b11ebd9 100644
--- a/src/lib/pocketbase/client.ts
+++ b/src/lib/pocketbase/client.ts
@@ -5,6 +5,7 @@ import { createTeamsService } from "./services/teams";
import { createMatchesService } from "./services/matches";
import { createReactionsService } from "./services/reactions";
import { createActivitiesService } from "./services/activities";
+import { createBadgesService } from "./services/badges";
import dotenv from 'dotenv';
dotenv.config();
@@ -37,6 +38,7 @@ class PocketBaseAdminClient {
Object.assign(this, createMatchesService(this.pb));
Object.assign(this, createReactionsService(this.pb));
Object.assign(this, createActivitiesService(this.pb));
+ Object.assign(this, createBadgesService(this.pb));
});
}
@@ -57,7 +59,8 @@ interface AdminClient
ReturnType,
ReturnType,
ReturnType,
- ReturnType {
+ ReturnType,
+ ReturnType {
authPromise: Promise;
}
diff --git a/src/lib/pocketbase/services/badges.ts b/src/lib/pocketbase/services/badges.ts
new file mode 100644
index 0000000..9edd3b7
--- /dev/null
+++ b/src/lib/pocketbase/services/badges.ts
@@ -0,0 +1,407 @@
+import PocketBase from "pocketbase";
+import { Badge, BadgeProgress } from "@/features/badges/types";
+import { transformBadge, transformBadgeProgress } from "@/lib/pocketbase/util/transform-types";
+
+export interface PlayerStats {
+ player_id: string;
+ matches: number;
+ wins: number;
+ losses: number;
+ total_cups_made: number;
+ total_cups_against: number;
+ margin_of_victory: number;
+}
+
+export function createBadgesService(pb: PocketBase) {
+ return {
+ async getBadge(id: string): Promise {
+ const result = await pb.collection("badges").getOne(id);
+ return transformBadge(result);
+ },
+
+ async listBadges(): Promise {
+ const results = await pb.collection("badges").getFullList({
+ sort: 'name',
+ });
+ return results.map(transformBadge);
+ },
+
+ async getBadgeProgress(id: string): Promise {
+ const result = await pb.collection("badge_progress").getOne(id, {
+ expand: 'badge,player',
+ });
+ return transformBadgeProgress(result);
+ },
+
+ async getPlayerBadgeProgress(playerId: string): Promise {
+ const results = await pb.collection("badge_progress").getFullList({
+ filter: `player = "${playerId}"`,
+ expand: 'badge',
+ });
+ return results.map(transformBadgeProgress);
+ },
+
+ async createBadgeProgress(data: {
+ badge: string;
+ player: string;
+ progress: number;
+ earned: boolean;
+ }): Promise {
+ return await pb.collection("badge_progress").create(data);
+ },
+
+ async updateBadgeProgress(id: string, data: {
+ progress?: number;
+ earned?: boolean;
+ }): Promise {
+ return await pb.collection("badge_progress").update(id, data);
+ },
+
+ async deleteBadgeProgress(id: string): Promise {
+ await pb.collection("badge_progress").delete(id);
+ return true;
+ },
+
+ async clearAllBadgeProgress(): Promise {
+ const existingProgress = await pb.collection("badge_progress").getFullList();
+ for (const progress of existingProgress) {
+ await pb.collection("badge_progress").delete(progress.id);
+ }
+ return existingProgress.length;
+ },
+
+ async calculateBadgeProgress(playerId: string, badge: Badge): Promise {
+ if (badge.type === "manual") {
+ return 0;
+ }
+
+ if (badge.type === "match") {
+ return await this.calculateMatchBadgeProgress(playerId, badge);
+ }
+
+ if (badge.type === "tournament") {
+ return await this.calculateTournamentBadgeProgress(playerId, badge);
+ }
+
+ return 0;
+ },
+
+ async calculateMatchBadgeProgress(playerId: string, badge: Badge): Promise {
+ const criteria = badge.criteria;
+
+ const stats = await pb.collection("player_stats").getFirstListItem(
+ `player_id = "${playerId}"`
+ ).catch(() => null);
+
+ if (!stats) return 0;
+
+ if (criteria.matches_played !== undefined) {
+ return stats.matches;
+ }
+
+ if (criteria.overtime_matches !== undefined || criteria.overtime_wins !== undefined) {
+ const matches = await pb.collection("matches").getFullList({
+ filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended" && ot_count > 0`,
+ expand: 'home,away,home.players,away.players',
+ });
+
+ if (criteria.overtime_matches !== undefined) {
+ return matches.length;
+ }
+
+ if (criteria.overtime_wins !== undefined) {
+ const overtimeWins = matches.filter(m => {
+ const isHome = m.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
+ m.expand?.home?.players?.includes(playerId);
+ const isAway = m.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
+ m.expand?.away?.players?.includes(playerId);
+
+ if (isHome) {
+ return m.home_cups > m.away_cups;
+ } else if (isAway) {
+ return m.away_cups > m.home_cups;
+ }
+ return false;
+ });
+ return overtimeWins.length;
+ }
+ }
+
+ if (criteria.margin_of_victory !== undefined) {
+ const matches = await pb.collection("matches").getFullList({
+ filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`,
+ expand: 'home,away,home.players,away.players',
+ });
+
+ const bigWins = matches.filter(m => {
+ const isHome = m.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
+ m.expand?.home?.players?.includes(playerId);
+ const isAway = m.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
+ m.expand?.away?.players?.includes(playerId);
+
+ if (isHome && m.home_cups > m.away_cups) {
+ return (m.home_cups - m.away_cups) >= criteria.margin_of_victory;
+ } else if (isAway && m.away_cups > m.home_cups) {
+ return (m.away_cups - m.home_cups) >= criteria.margin_of_victory;
+ }
+ return false;
+ });
+
+ return bigWins.length > 0 ? 1 : 0;
+ }
+
+ return 0;
+ },
+
+ async calculateTournamentBadgeProgress(playerId: string, badge: Badge): Promise {
+ const criteria = badge.criteria;
+
+ const matches = await pb.collection("matches").getFullList({
+ filter: `(home.players.id ?~ "${playerId}" || away.players.id ?~ "${playerId}") && status = "ended"`,
+ expand: 'tournament,home,away,home.players,away.players',
+ });
+
+ const tournamentIds = new Set(matches.map(m => m.tournament));
+ const tournamentsAttended = tournamentIds.size;
+
+ if (criteria.tournaments_attended !== undefined) {
+ return tournamentsAttended;
+ }
+
+ if (criteria.tournament_wins !== undefined) {
+ if (tournamentIds.size === 0) return 0;
+
+ let tournamentWins = 0;
+
+ for (const tournamentId of tournamentIds) {
+ const tournamentMatches = await pb.collection("matches").getFullList({
+ filter: `tournament = "${tournamentId}" && status = "ended"`,
+ expand: 'home,away,home.players,away.players',
+ });
+
+ const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket);
+ const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
+ (!highest || current.lid > highest.lid) ? current : highest, null);
+
+ if (finalsMatch && finalsMatch.status === 'ended') {
+ const finalsWinnerId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
+
+ const winningTeam = finalsMatch.expand?.[finalsWinnerId === finalsMatch.home ? 'home' : 'away'];
+ const winningPlayers = winningTeam?.expand?.players || winningTeam?.players || [];
+
+ const playerWon = winningPlayers.some((p: any) =>
+ (typeof p === 'string' ? p : p.id) === playerId
+ );
+
+ if (playerWon) {
+ tournamentWins++;
+ }
+ }
+ }
+
+ return tournamentWins;
+ }
+
+ if (criteria.placement !== undefined && typeof criteria.placement === 'number') {
+ let placementCount = 0;
+
+ for (const tournamentId of tournamentIds) {
+ const tournamentMatches = await pb.collection("matches").getFullList({
+ filter: `tournament = "${tournamentId}" && status = "ended"`,
+ expand: 'home,away,home.players,away.players',
+ });
+
+ if (criteria.placement === 2) {
+ const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket);
+ const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
+ (!highest || current.lid > highest.lid) ? current : highest, null);
+
+ if (finalsMatch && finalsMatch.status === 'ended') {
+ const finalsLoserId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
+
+ const losingTeam = finalsMatch.expand?.[finalsLoserId === finalsMatch.home ? 'home' : 'away'];
+ const losingPlayers = losingTeam?.expand?.players || losingTeam?.players || [];
+
+ const playerLost = losingPlayers.some((p: any) =>
+ (typeof p === 'string' ? p : p.id) === playerId
+ );
+
+ if (playerLost) {
+ placementCount++;
+ }
+ }
+ }
+
+ if (criteria.placement === 3) {
+ const losersMatches = tournamentMatches.filter(m => m.is_losers_bracket);
+ const losersFinale = losersMatches.reduce((highest: any, current: any) =>
+ (!highest || current.lid > highest.lid) ? current : highest, null);
+
+ if (losersFinale && losersFinale.status === 'ended') {
+ const losersFinaleLoserId = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
+
+ const losingTeam = losersFinale.expand?.[losersFinaleLoserId === losersFinale.home ? 'home' : 'away'];
+ const losingPlayers = losingTeam?.expand?.players || losingTeam?.players || [];
+
+ const playerLost = losingPlayers.some((p: any) =>
+ (typeof p === 'string' ? p : p.id) === playerId
+ );
+
+ if (playerLost) {
+ placementCount++;
+ }
+ }
+ }
+ }
+
+ return placementCount;
+ }
+
+ if (criteria.tournament_record !== undefined) {
+ const tournaments = await pb.collection("tournaments").getFullList({
+ sort: 'start_time',
+ });
+
+ let timesWent02 = 0;
+
+ for (const tournamentId of tournamentIds) {
+ const tournament = tournaments.find(t => t.id === tournamentId);
+ if (!tournament) continue;
+
+ const tournamentMatches = matches.filter(m => m.tournament === tournamentId);
+
+ let wins = 0;
+ let losses = 0;
+
+ for (const match of tournamentMatches) {
+ const isHome = match.expand?.home?.expand?.players?.some((p: any) => p.id === playerId) ||
+ match.expand?.home?.players?.includes(playerId);
+ const isAway = match.expand?.away?.expand?.players?.some((p: any) => p.id === playerId) ||
+ match.expand?.away?.players?.includes(playerId);
+
+ if (isHome && match.home_cups > match.away_cups) {
+ wins++;
+ } else if (isAway && match.away_cups > match.home_cups) {
+ wins++;
+ } else {
+ losses++;
+ }
+ }
+
+ const record = `${wins}-${losses}`;
+
+ if (record === criteria.tournament_record) {
+ if (criteria.won_previous !== undefined && criteria.won_previous === true) {
+ const currentIndex = tournaments.findIndex(t => t.id === tournamentId);
+ if (currentIndex > 0) {
+ const previousTournament = tournaments[currentIndex - 1];
+ if (previousTournament.winner_id === playerId) {
+ timesWent02++;
+ }
+ }
+ } else {
+ timesWent02++;
+ }
+ }
+ }
+
+ return timesWent02 > 0 ? 1 : 0;
+ }
+
+ if (criteria.consecutive_wins !== undefined) {
+ const tournaments = await pb.collection("tournaments").getFullList({
+ sort: 'start_time',
+ });
+
+ let consecutiveWins = 0;
+ let maxConsecutiveWins = 0;
+
+ for (const tournament of tournaments) {
+ if (!tournamentIds.has(tournament.id)) continue;
+
+ if (tournament.winner_id === playerId) {
+ consecutiveWins++;
+ maxConsecutiveWins = Math.max(maxConsecutiveWins, consecutiveWins);
+ } else {
+ consecutiveWins = 0;
+ }
+ }
+
+ return maxConsecutiveWins >= criteria.consecutive_wins ? 1 : 0;
+ }
+
+ return 0;
+ },
+
+ getTargetProgress(badge: Badge): number {
+ if (badge.type === "manual") {
+ return 1;
+ }
+
+ const criteria = badge.criteria;
+
+ return (
+ criteria.matches_played ||
+ criteria.tournament_wins ||
+ criteria.tournaments_attended ||
+ criteria.overtime_matches ||
+ criteria.overtime_wins ||
+ criteria.consecutive_wins ||
+ 1
+ );
+ },
+
+ async migrateBadgeProgress(): Promise<{
+ success: boolean;
+ playersProcessed: number;
+ progressRecordsCreated: number;
+ totalBadgesEarned: number;
+ averageBadgesPerPlayer: string;
+ }> {
+ await this.clearAllBadgeProgress();
+
+ const badges = await this.listBadges();
+
+ const playerStats = await pb.collection("player_stats").getFullList();
+ const uniquePlayers = new Set(playerStats.map(s => s.player_id));
+
+ let totalProgressRecords = 0;
+ let totalBadgesEarned = 0;
+
+ for (const playerId of uniquePlayers) {
+ for (const badge of badges) {
+ try {
+ const progress = await this.calculateBadgeProgress(playerId, badge);
+ const target = this.getTargetProgress(badge);
+ const earned = progress >= target;
+
+ if (progress > 0 || earned) {
+ await this.createBadgeProgress({
+ badge: badge.id,
+ player: playerId,
+ progress: progress,
+ earned: earned,
+ });
+
+ totalProgressRecords++;
+
+ if (earned) {
+ totalBadgesEarned++;
+ }
+ }
+ } catch (error: any) {
+ console.error(`Error processing badge "${badge.name}" for player ${playerId}:`, error.message);
+ }
+ }
+ }
+
+ return {
+ success: true,
+ playersProcessed: uniquePlayers.size,
+ progressRecordsCreated: totalProgressRecords,
+ totalBadgesEarned: totalBadgesEarned,
+ averageBadgesPerPlayer: (totalBadgesEarned / uniquePlayers.size).toFixed(2),
+ };
+ },
+ };
+}
diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts
index 5193495..93ac5e9 100644
--- a/src/lib/pocketbase/util/transform-types.ts
+++ b/src/lib/pocketbase/util/transform-types.ts
@@ -3,6 +3,7 @@ import { Match } from "@/features/matches/types";
import { Player, PlayerInfo } from "@/features/players/types";
import { Team, TeamInfo } from "@/features/teams/types";
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
+import { Badge, BadgeInfo, BadgeProgress } from "@/features/badges/types";
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
// this file transforms raw pocketbase results to our types
@@ -278,3 +279,36 @@ export function transformReaction(record: any) {
match: record.match
};
}
+
+export function transformBadgeInfo(record: any): BadgeInfo {
+ return {
+ id: record.id,
+ name: record.name,
+ key: record.key,
+ description: record.description,
+ type: record.type,
+ progressive: record.progressive,
+ order: record.order ?? 999,
+ };
+}
+
+export function transformBadge(record: any): Badge {
+ return {
+ ...transformBadgeInfo(record),
+ criteria: record.criteria,
+ created: record.created,
+ updated: record.updated,
+ };
+}
+
+export function transformBadgeProgress(record: any): BadgeProgress {
+ return {
+ id: record.id,
+ badge: record.expand?.badge ? transformBadgeInfo(record.expand.badge) : record.badge,
+ player: record.player,
+ progress: record.progress,
+ earned: record.earned,
+ created: record.created,
+ updated: record.updated,
+ };
+}