Skip to content

Commit

Permalink
✨ Add achievements
Browse files Browse the repository at this point in the history
  • Loading branch information
bal7hazar committed Jan 24, 2025
1 parent 12653f8 commit 625b9d1
Show file tree
Hide file tree
Showing 28 changed files with 1,416 additions and 135 deletions.
2 changes: 1 addition & 1 deletion client/.env.development
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
VITE_CARTRIDGE_API_URL="https://api.cartridge.gg/x/cartridge"
VITE_CARTRIDGE_API_URL="https://api.cartridge.gg"
VITE_RPC_URL="https://api.cartridge.gg/x/starknet/mainnet"
VITE_TORII_URL="https://api.cartridge.gg/x/arcade/torii"
122 changes: 122 additions & 0 deletions client/src/components/achievements/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Spinner } from "@cartridge/ui-next";
import { TrophiesTab, LeaderboardTab } from "./tab";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Trophies } from "./trophies";
import { Leaderboard } from "./leaderboard";
import { useData } from "@/hooks/context";
import { useArcade } from "@/hooks/arcade";
import { GameModel } from "@bal7hazar/arcade-sdk";
import { addAddressPadding } from "starknet";
import { useAccount } from "@starknet-react/core";
import { useProject } from "@/hooks/project";

export function Achievements() {
const { address: self } = useAccount();
const {
trophies: { achievements, players, isLoading },
setAccountAddress,
} = useData();

const { pins, games } = useArcade();

const { address } = useParams<{ address: string }>();
const { project, namespace } = useProject();

const [activeTab, setActiveTab] = useState<"trophies" | "leaderboard">(
"trophies",
);

const game: GameModel | undefined = useMemo(() => {
return Object.values(games).find(
(game) => game.namespace === namespace && game.project === project,
);
}, [games, project, namespace]);

const { pinneds, completed, total } = useMemo(() => {
const ids = pins[addAddressPadding(address || self || "0x0")] || [];
const pinneds = achievements
.filter((item) => ids.includes(item.id))
.sort((a, b) => parseFloat(a.percentage) - parseFloat(b.percentage))
.slice(0, 3); // There is a front-end limit of 3 pinneds
const completed = achievements.filter((item) => item.completed).length;
const total = achievements.length;
return { pinneds, completed, total };
}, [achievements, pins, address, self]);

const { rank, earnings } = useMemo(() => {
const rank =
players.findIndex(
(player) =>
BigInt(player.address || 0) === BigInt(address || self || 0),
) + 1;
const earnings =
players.find(
(player) =>
BigInt(player.address || 0) === BigInt(address || self || 0),
)?.earnings || 0;
return { rank, earnings };
}, [address, self, players]);

const isSelf = useMemo(() => {
return !address || address === self;
}, [address, self]);

useEffect(() => {
setAccountAddress(address || self || "");
}, [address, self, setAccountAddress]);

return (
<div className="w-3/4">
{achievements.length ? (
<div className="pb-4 select-none flex flex-col gap-y-6">
{isSelf && (
<div className="flex justify-between gap-x-3 gap-y-4">
<TrophiesTab
active={activeTab === "trophies"}
completed={completed}
total={total}
onClick={() => setActiveTab("trophies")}
/>
<LeaderboardTab
active={activeTab === "leaderboard"}
rank={rank}
earnings={earnings}
onClick={() => setActiveTab("leaderboard")}
/>
</div>
)}
{(!isSelf || activeTab === "trophies") && (
<Trophies
achievements={achievements}
softview={!isSelf}
enabled={pinneds.length < 3}
game={game}
pins={pins}
/>
)}
{isSelf && activeTab === "leaderboard" && (
<Leaderboard
players={players}
address={self || ""}
achievements={achievements}
pins={pins}
/>
)}
</div>
) : isLoading ? (
<div className="pb-4 select-none">
<div className="flex justify-center items-center h-full border border-dashed rounded-md text-muted-foreground/10 mb-4">
<Spinner className="text-muted-foreground/30" size="lg" />
</div>
</div>
) : (
<div className="pb-4 select-none">
<div className="flex justify-center items-center h-full border border-dashed rounded-md text-muted-foreground/10 mb-4">
<p className="text-muted-foreground/30">No trophies available</p>
</div>
</div>
)}
</div>
);
}
159 changes: 159 additions & 0 deletions client/src/components/achievements/leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {
cn,
ScrollArea,
SpaceInvaderIcon,
SparklesIcon,
StateIconProps,
} from "@cartridge/ui-next";
import { Link, useLocation } from "react-router-dom";
import { Item, Player } from "@/hooks/achievements";
import { useUsername } from "@/hooks/account";
import { useMemo } from "react";
import { addAddressPadding } from "starknet";

export function Leaderboard({
players,
address,
achievements,
pins,
}: {
players: Player[];
address: string;
achievements: Item[];
pins: { [playerId: string]: string[] };
}) {
return (
<ScrollArea className="h-[600px]">
<div className="flex flex-col gap-y-px">
{players.map((player, index) => (
<Row
key={player.address}
self={BigInt(player.address || 0) === BigInt(address || 1)}
address={player.address}
earnings={player.earnings}
completeds={player.completeds}
achievements={achievements}
rank={index + 1}
pins={pins}
/>
))}
</div>
</ScrollArea>
);
}

function Row({
self,
address,
earnings,
rank,
completeds,
achievements,
pins,
}: {
self: boolean;
address: string;
earnings: number;
rank: number;
completeds: string[];
achievements: Item[];
pins: { [playerId: string]: string[] };
}) {
const { username } = useUsername({ address });
const location = useLocation();

const path = useMemo(() => {
if (self) return location.pathname;
return [...location.pathname.split("/"), address].join("/");
}, [location.pathname, address, self]);

const trophies = useMemo(() => {
const ids = (address ? pins[addAddressPadding(address)] : []) || [];
const pinneds = achievements
.filter((achievement) => completeds.includes(achievement.id))
.filter((item) => ids.includes(item.id))
.sort((a, b) => parseFloat(a.percentage) - parseFloat(b.percentage))
.slice(0, 3); // There is a front-end limit of 3 pinneds
return pinneds
.filter((achievement) => achievement)
.map((achievement) => ({
address,
id: achievement.id,
icon: achievement.icon,
}));
}, [achievements, completeds, address, pins]);

return (
<Link
className={cn("flex", self && "sticky top-0 bottom-0 z-10")}
to={path}
>
<div
className={cn(
"grow flex justify-between items-center px-3 py-2 text-sm gap-x-3 sticky top-0 bg-secondary hover:bg-quaternary",
self && "bg-quaternary text-primary",
)}
>
<div className="flex items-center justify-between grow sticky top-0 gap-x-3">
<div className="flex items-center gap-x-4 sticky top-0">
<p className="text-muted-foreground min-w-6 sticky top-0">{`${rank}.`}</p>
<User
username={!username ? address.slice(0, 9) : username}
Icon={SpaceInvaderIcon}
/>
</div>
<Trophies self={self} trophies={trophies} />
</div>
<Earnings earnings={earnings} self={self} />
</div>
</Link>
);
}

function User({
username,
Icon,
}: {
username: string;
Icon: React.ComponentType<StateIconProps>;
}) {
return (
<div className="flex items-center gap-x-2">
<Icon className="shrink-0" size="default" variant="line" />
<p className="text-ellipsis line-clamp-1 break-all">{username}</p>
</div>
);
}

function Trophies({
self,
trophies,
}: {
self: boolean;
trophies: { address: string; id: string; icon: string }[];
}) {
return (
<div className="flex items-center gap-x-2">
{trophies.map((trophy) => (
<div
key={`${trophy.address}-${trophy.id}`}
className={cn(
"w-6 h-6 border rounded-md flex items-center justify-center",
self ? "border-quinary" : "border-quaternary",
)}
>
<div className={cn("w-4 h-4", trophy.icon, "fa-solid")} />
</div>
))}
</div>
);
}

function Earnings({ earnings, self }: { earnings: number; self: boolean }) {
return (
<div className="flex items-center justify-end gap-x-2 min-w-16">
<SparklesIcon size="default" variant={self ? "solid" : "line"} />
<p>{earnings}</p>
</div>
);
}
Loading

0 comments on commit 625b9d1

Please sign in to comment.