313 lines
9.9 KiB
TypeScript
313 lines
9.9 KiB
TypeScript
import { Box, Text, Progress, Image, Popover, Title, Stack } from "@mantine/core";
|
|
import { usePlayerBadges, useAllBadges } from "../queries";
|
|
import { useAuth } from "@/contexts/auth-context";
|
|
import { Badge, BadgeProgress } from "../types";
|
|
import { useMemo, useState } from "react";
|
|
import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react";
|
|
|
|
interface BadgeShowcaseProps {
|
|
playerId: string;
|
|
}
|
|
|
|
interface BadgeDisplay {
|
|
badge: Badge;
|
|
progress?: BadgeProgress;
|
|
earned: boolean;
|
|
progressText: string;
|
|
}
|
|
|
|
interface BadgeIconProps {
|
|
badge: Badge;
|
|
filled: boolean;
|
|
}
|
|
|
|
export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?: number }) => {
|
|
const [imageError, setImageError] = useState(false);
|
|
const imagePath = `/static/img/${badge.key}.png`;
|
|
|
|
if (imageError) {
|
|
return filled ? (
|
|
<MedalIcon
|
|
size={size}
|
|
weight="fill"
|
|
color="var(--mantine-primary-color-6)"
|
|
/>
|
|
) : (
|
|
<LockKeyIcon
|
|
size={size - 4}
|
|
weight="regular"
|
|
color="var(--mantine-color-dimmed)"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Image
|
|
src={imagePath}
|
|
alt={badge.name}
|
|
width={size}
|
|
height={size}
|
|
onError={() => setImageError(true)}
|
|
style={{
|
|
objectFit: 'contain',
|
|
opacity: filled ? 1 : 0.4,
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
|
|
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;
|
|
}
|
|
|
|
const isVeteranBadge = /^veteran_\d+_badge$/.test(badge.key);
|
|
if (isVeteranBadge && !earned) {
|
|
continue;
|
|
}
|
|
|
|
if (badge.key === 'new_player_badge') {
|
|
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 (
|
|
<Box mb="lg">
|
|
<Box
|
|
px="md"
|
|
style={{
|
|
maxHeight: '240px',
|
|
overflowY: 'auto',
|
|
overflowX: 'hidden',
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(85px, 1fr))',
|
|
gap: 'var(--mantine-spacing-md)',
|
|
paddingBottom: 'var(--mantine-spacing-sm)',
|
|
width: '100%',
|
|
}}
|
|
>
|
|
{badgesToDisplay.map((display) => {
|
|
const isStackableBadge = ['winner_badge', 'silver_medal_badge', 'bronze_medal_badge'].includes(display.badge.key);
|
|
const stackCount = display.earned && isStackableBadge
|
|
? (display.progress?.progress || 0)
|
|
: 1;
|
|
const showStack = stackCount > 1;
|
|
|
|
return (
|
|
<Popover key={display.badge.id} width={280} position="top" withArrow shadow="md" withinPortal>
|
|
<Popover.Target>
|
|
<Box
|
|
style={{
|
|
cursor: "pointer",
|
|
transition: 'all 0.2s ease',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{showStack && (
|
|
<>
|
|
{[...Array(Math.min(stackCount - 1, 2))].map((_, i) => (
|
|
<Box
|
|
key={i}
|
|
style={{
|
|
width: '100px',
|
|
height: '100px',
|
|
borderRadius: '12px',
|
|
background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
|
|
border: '2px solid var(--mantine-primary-color-5)',
|
|
position: 'absolute',
|
|
top: `${(i + 1) * 4}px`,
|
|
left: `${(i + 1) * 4}px`,
|
|
opacity: 0.3 - (i * 0.1),
|
|
zIndex: -(i + 1),
|
|
}}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
<Box
|
|
style={{
|
|
width: '100px',
|
|
height: '100px',
|
|
borderRadius: '12px',
|
|
background: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
|
|
border: display.earned
|
|
? '2px solid var(--mantine-primary-color-6)'
|
|
: '2px dashed var(--mantine-primary-color-4)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexDirection: 'column',
|
|
gap: '4px',
|
|
padding: 'var(--mantine-spacing-sm)',
|
|
position: 'relative',
|
|
boxShadow: display.earned
|
|
? '0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-6) 20%, transparent)'
|
|
: 'none',
|
|
opacity: display.earned ? 1 : 0.4,
|
|
zIndex: 1,
|
|
}}
|
|
>
|
|
<BadgeIcon badge={display.badge} filled={display.earned} />
|
|
|
|
{showStack && (
|
|
<Box
|
|
style={{
|
|
position: 'absolute',
|
|
top: '4px',
|
|
right: '4px',
|
|
color: 'var(--mantine-primary-color-6)',
|
|
width: '20px',
|
|
height: '20px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '10px',
|
|
fontWeight: 700,
|
|
}}
|
|
>
|
|
x{stackCount}
|
|
</Box>
|
|
)}
|
|
|
|
<Text
|
|
size="xs"
|
|
px={4}
|
|
fw={display.earned ? 600 : 500}
|
|
ta="center"
|
|
c={display.earned ? undefined : 'dimmed'}
|
|
style={{ lineHeight: 1.1 }}
|
|
>
|
|
{display.badge.name}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
</Popover.Target>
|
|
<Popover.Dropdown>
|
|
<Stack gap={4} align="center">
|
|
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<BadgeIcon badge={display.badge} filled={display.earned} size={80} />
|
|
</Box>
|
|
|
|
<Title order={5} ta="center">
|
|
{display.badge.name}
|
|
</Title>
|
|
|
|
<Text size="sm" c="dimmed" ta="center">
|
|
{display.badge.description}
|
|
</Text>
|
|
|
|
{isCurrentUser && (
|
|
<Box>
|
|
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<Text size="sm" fw={500} c="dimmed">
|
|
Progress
|
|
</Text>
|
|
<Text size="sm" fw={600} c="dimmed">
|
|
{display.progressText}
|
|
</Text>
|
|
</Box>
|
|
<Progress
|
|
value={(display.progress?.progress || 0) / getTargetProgress(display.badge) * 100}
|
|
size="sm"
|
|
radius="sm"
|
|
color={display.earned ? "green" : undefined}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Stack>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
);
|
|
})}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
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;
|