Skip to content

Commit

Permalink
add xp metagame to profile (#1473)
Browse files Browse the repository at this point in the history
* feat: add progress bar and polygon

* feat: implement xp ranking, leveling logic

* chore: remove console.log

* feat: add xp to activity card, refactor xp system

* feat: add xp to activity card, refactor xp system

* fix: cache experiences count on server

* feat: add xp to Account and AccountModal

* fix(a11y): add dialog description to modal

* UI(ActivityCard): move XP to the right in gold

* feat: add activity chart

* feat: add tooltip to chart

* UI(ProfileHero): avatar level indicator refinements

* feat: style tooltip and group xp entries

* chore: adjust barchart size

* fix: size experience card with badge

* refactor: resize RewardBadge

* refactor: add progress ui component

* fix: assign rank properly on profile

* chore: floor levels for simplicity

* chore: revalidate experiences every 20 min

* fix: Experience & Top contributions title colors in light mode

* UI: whitespace refinement

* chore: add fallback to activity chart

* UI(experiences): impros/fixes

* fix: reorder useEffect calls, adjust startTime

* UI: account modal & progress refinements

* cleanup(LevelBadge): duplicated text style

* copy(ActivityChart): fallback fix

* ActivityChart batching / fallback rendering impro

---------

Co-authored-by: valid <[email protected]>
  • Loading branch information
dominik-stumpf and dovalid authored Sep 12, 2024
1 parent 853db20 commit 32510d7
Show file tree
Hide file tree
Showing 25 changed files with 592 additions and 60 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@emotion/styled": "^11.11.0",
"@fuels/connectors": "^0.5.0",
"@fuels/react": "^0.20.0",
"@guildxyz/types": "^1.10.8",
"@guildxyz/types": "^1.10.10",
"@hcaptcha/react-hcaptcha": "^1.4.4",
"@hookform/resolvers": "^3.3.4",
"@lexical/code": "^0.12.0",
Expand Down
20 changes: 20 additions & 0 deletions src/app/(marketing)/profile/[username]/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const MAX_LEVEL = 100
export const MAX_XP = 11e4
export const RANKS = [
{ color: "#78c93d", title: "novice", polygonCount: 20 },
{ color: "#88d525", title: "learner", polygonCount: 20 },
{ color: "#f6ca45", title: "knight", polygonCount: 4 },
{ color: "#f19b38", title: "veteran", polygonCount: 4 },
{ color: "#ec5a53", title: "champion", polygonCount: 4 },
{ color: "#53adf0", title: "hero", polygonCount: 5 },
{ color: "#c385f8", title: "master", polygonCount: 5 },
{ color: "#3e6fc3", title: "grand master", polygonCount: 5 },
{ color: "#be4681", title: "legend", polygonCount: 6 },
{ color: "#000000", title: "mythic", polygonCount: 6 },
{
color: "#eeeeee",
title: "???",
requiredXp: 1e19,
polygonCount: 6,
},
] as const
21 changes: 21 additions & 0 deletions src/app/(marketing)/profile/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@ const fetchPublicProfileData = async ({
: []
const guildsZipped = guildRequests.map(({ pathname }, i) => [pathname, guilds[i]])
const rolesZipped = roleRequests.map(({ pathname }, i) => [pathname, roles[i]])
const experiencesRequest = new URL(`/v2/profiles/${username}/experiences`, api)
const experiences = await ssrFetcher<Schemas["Experience"][]>(experiencesRequest, {
next: {
revalidate: 1200,
},
})
const experienceCountRequest = new URL(
`/v2/profiles/${username}/experiences?count=true`,
api
)
const experienceCount = await ssrFetcher<number>(experienceCountRequest, {
next: {
revalidate: 1200,
},
})

return {
profile,
fallback: Object.fromEntries(
Expand All @@ -167,6 +183,11 @@ const fetchPublicProfileData = async ({
[farcasterProfilesRequest.pathname, farcasterProfiles],
[neynarRequest?.href, fcFollowers],
[referredUsersRequest.pathname, referredUsers],
[experiencesRequest.pathname, experiences],
[
experienceCountRequest.pathname + experienceCountRequest.search,
experienceCount,
],
...collectionsZipped,
...guildsZipped,
...rolesZipped,
Expand Down
3 changes: 3 additions & 0 deletions src/app/(marketing)/profile/[username]/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { RANKS } from "./constants"

export type Rank = (typeof RANKS)[number]
166 changes: 166 additions & 0 deletions src/app/(marketing)/profile/_components/ActivityChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Skeleton } from "@/components/ui/Skeleton"
import { Schemas } from "@guildxyz/types"
import { localPoint } from "@visx/event"
import { Group } from "@visx/group"
import ParentSize from "@visx/responsive/lib/components/ParentSize"
import { scaleBand, scaleLinear } from "@visx/scale"
import { Bar } from "@visx/shape"
import { useTooltip, useTooltipInPortal } from "@visx/tooltip"
import { useMemo } from "react"
import { useExperienceProgression } from "../_hooks/useExperienceProgression"
import { useExperiences } from "../_hooks/useExperiences"

type TooltipData = Schemas["Experience"]
const verticalMargin = 0

const getX = (xp: Schemas["Experience"]) => xp.id.toString()
const getY = (xp: Schemas["Experience"]) => xp.amount

export type BarsProps = {
width: number
height: number
}

let tooltipTimeout: number

const ActivityChartChildren = ({
width,
height,
rawData,
}: BarsProps & {
rawData: Schemas["Experience"][]
}) => {
const {
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip,
} = useTooltip<TooltipData>()

const { containerRef, TooltipInPortal } = useTooltipInPortal({
scroll: true,
})
const xp = useExperienceProgression()
const groupedData = new Map<number, Schemas["Experience"][]>()
for (const rawXp of rawData) {
const createdAt = new Date(rawXp.createdAt)
const commonDay = new Date(
createdAt.getFullYear(),
createdAt.getMonth(),
createdAt.getDate()
).valueOf()
groupedData.set(commonDay, [...(groupedData.get(commonDay) ?? []), rawXp])
}
const data = [...groupedData.entries()]
.reduce<Schemas["Experience"][]>((acc, [_, xpGroup]) => {
return [
...acc,
{
...xpGroup[0],
amount: xpGroup.reduce((sumAcc, xp) => sumAcc + xp.amount, 0),
},
]
}, [])
.sort(
(a, b) => new Date(a.createdAt).valueOf() - new Date(b.createdAt).valueOf()
)

const xMax = width
const yMax = height - verticalMargin
const xScale = useMemo(
() =>
scaleBand<string>({
range: [0, Math.min(data.length * 18, xMax)],
round: true,
domain: data.map(getX),
padding: 0.4,
}),
[xMax]
)
const yScale = useMemo(
() =>
scaleLinear<number>({
range: [yMax, 0],
round: true,
domain: [0, Math.max(...data.map(getY))],
}),
[yMax]
)

return width < 10 ? null : (
<div className="relative">
<svg width={width} height={height} ref={containerRef}>
<Group top={verticalMargin / 2}>
{data.map((currentXp) => {
const x = getX(currentXp)
const barWidth = xScale.bandwidth()
const barHeight = yMax - (yScale(getY(currentXp)) ?? 0)
const barX = xScale(x)
const barY = yMax - barHeight
return (
<Bar
ry={4}
key={currentXp.id}
x={barX}
y={barY}
width={barWidth}
height={barHeight}
fill={xp?.rank.color}
onMouseLeave={() => {
tooltipTimeout = window.setTimeout(() => {
hideTooltip()
}, 300)
}}
onMouseMove={(event) => {
if (tooltipTimeout) clearTimeout(tooltipTimeout)
const eventSvgCoords = localPoint(event)
const left = (barX || 0) + barWidth / 2
showTooltip({
tooltipData: currentXp,
tooltipTop: eventSvgCoords?.y,
tooltipLeft: left,
})
}}
/>
)
})}
</Group>
</svg>
{tooltipOpen && tooltipData && (
<TooltipInPortal
top={tooltipTop}
left={tooltipLeft}
unstyled
applyPositionStyle
className="rounded border bg-card px-2 py-1 text-sm"
>
<strong>+{tooltipData.amount} XP</strong>
<div className="text-muted-foreground">
{new Date(tooltipData.createdAt).toLocaleDateString()}
</div>
</TooltipInPortal>
)}
</div>
)
}

export const ActivityChart = () => {
const { data: rawData } = useExperiences({ count: false })

if (!rawData) return <Skeleton className="h-7 w-full" />

if (rawData.length === 0)
return <p className="text-muted-foreground">There's no activity this month</p>

return (
<div className="h-7">
<ParentSize>
{({ width, height }) => (
<ActivityChartChildren height={height} width={width} rawData={rawData} />
)}
</ParentSize>
</div>
)
}
41 changes: 41 additions & 0 deletions src/app/(marketing)/profile/_components/LevelBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Polygon } from "@/components/Polygon"
import { VariantProps, cva } from "class-variance-authority"
import { Rank } from "../[username]/types"

const levelBadgeVariants = cva("flex items-center justify-center", {
variants: {
size: {
md: "size-7 text-xs",
lg: "text-lg md:text-xl size-10 md:size-12",
},
},
defaultVariants: {
size: "md",
},
})

type LevelBadgeProps = {
levelIndex: number
rank: Rank
className?: string
} & VariantProps<typeof levelBadgeVariants>

export const LevelBadge = ({
rank,
levelIndex,
size,
className,
}: LevelBadgeProps) => {
return (
<div className={levelBadgeVariants({ size, className })}>
<Polygon
sides={rank.polygonCount}
color={rank.color}
className="brightness-75"
/>
<span className="-mt-0.5 absolute font-bold font-display text-white">
{levelIndex}
</span>
</div>
)
}
47 changes: 44 additions & 3 deletions src/app/(marketing)/profile/_components/Profile.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"use client"
import { useWeb3ConnectionManager } from "@/components/Web3ConnectionManager/hooks/useWeb3ConnectionManager"
import { Card } from "@/components/ui/Card"
import { ProgressIndicator, ProgressRoot } from "@/components/ui/Progress"
import { cn } from "@/lib/utils"
import { Info } from "@phosphor-icons/react"
import { PropsWithChildren } from "react"
import { ContributionCard } from "../_components/ContributionCard"
import { EditContributions } from "../_components/EditContributions"
import { ProfileOwnerGuard } from "../_components/ProfileOwnerGuard"
import { useContributions } from "../_hooks/useContributions"
import { useExperienceProgression } from "../_hooks/useExperienceProgression"
import { useProfile } from "../_hooks/useProfile"
import { useReferredUsers } from "../_hooks/useReferredUsers"
import { ActivityChart } from "./ActivityChart"
import { LevelBadge } from "./LevelBadge"
import { ProfileMainSkeleton } from "./ProfileSkeleton"
import { RecentActivity } from "./RecentActivity/RecentActivity"
import RecentActivityFallback from "./RecentActivity/RecentActivityFallback"
Expand All @@ -19,12 +23,49 @@ export const Profile = () => {
const { data: contributions } = useContributions()
const { data: referredUsers } = useReferredUsers()
const { isWeb3Connected } = useWeb3ConnectionManager()
const xp = useExperienceProgression()

if (!profile || !contributions || !referredUsers) return <ProfileMainSkeleton />
if (!profile || !contributions || !referredUsers || !xp)
return <ProfileMainSkeleton />

return (
<>
<div className="mb-3 flex items-center justify-between" data-theme="dark">
<div className="mb-12">
<div data-theme="dark" className="mb-3">
<SectionTitle>Experience</SectionTitle>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Card className="flex items-center gap-4 p-6">
<LevelBadge
levelIndex={xp.levelIndex}
rank={xp.rank}
size="lg"
className=""
/>
<div className="-mt-1 flex grow flex-col gap-2">
<div className="flex flex-col justify-between sm:flex-row">
<h3 className="font-bold capitalize">{xp.rank.title}</h3>
<p className="text-muted-foreground">
{`${xp.experienceCount} / ${xp.level} XP`}
</p>
</div>
<ProgressRoot>
<ProgressIndicator
value={xp.progress}
style={{ background: xp.rank.color }}
/>
</ProgressRoot>
</div>
</Card>
<Card className="space-y-3 p-6 pt-5">
<div className="flex flex-col items-start justify-between gap-2 sm:flex-row">
<h3 className="font-bold">Engagement this month</h3>
</div>
<ActivityChart />
</Card>
</div>
</div>
<div className="mb-3 flex items-center justify-between">
<SectionTitle>Top contributions</SectionTitle>
<ProfileOwnerGuard>
<EditContributions />
Expand All @@ -48,7 +89,7 @@ export const Profile = () => {
<ContributionCard contribution={contribution} key={contribution.id} />
))}
</div>
<div className="mt-16">
<div className="mt-14">
<SectionTitle className="mb-3">Recent activity</SectionTitle>
{isWeb3Connected ? <RecentActivity /> : <RecentActivityFallback />}
</div>
Expand Down
Loading

0 comments on commit 32510d7

Please sign in to comment.