From 759019bb526d6ee06038cc16ccaf7c6e22031ba4 Mon Sep 17 00:00:00 2001 From: solaawojobi00-bit Date: Tue, 20 Oct 2026 11:11:03 +0100 Subject: [PATCH] feat(profile): add NFT badge showcase to wallet profile page (#1200) --- src/components/common/WalletBadgeShowcase.tsx | 128 ++++++++++++++++++ src/hooks/useWallet.ts | 14 +- src/lib/queryKeys.ts | 1 + src/pages/ProfilePage.tsx | 5 + src/services/wallet.service.ts | 27 ++++ src/utils/walletBadges.utils.ts | 84 ++++++++++++ 6 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/components/common/WalletBadgeShowcase.tsx create mode 100644 src/utils/walletBadges.utils.ts diff --git a/src/components/common/WalletBadgeShowcase.tsx b/src/components/common/WalletBadgeShowcase.tsx new file mode 100644 index 00000000..3edeae5b --- /dev/null +++ b/src/components/common/WalletBadgeShowcase.tsx @@ -0,0 +1,128 @@ +import { useMemo } from 'react'; +import { format, isValid, parseISO } from 'date-fns'; +import { Lock } from 'lucide-react'; +import { Tooltip } from '@/components/ui/tooltip'; +import Skeleton from '@/components/ui/skeleton'; +import { useWalletBadges } from '@/hooks/useWallet'; +import { splitBadges } from '@/utils/walletBadges.utils'; +import { cn } from '@/lib/utils'; + +interface WalletBadgeShowcaseProps { + wallet: string; + className?: string; +} + +function formatEarnedDate(earnedAt: string): string { + const date = parseISO(earnedAt); + return isValid(date) ? format(date, 'MMM d, yyyy') : 'Date unavailable'; +} + +const GRID_CLASS = 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5'; + +export default function WalletBadgeShowcase({ + wallet, + className, +}: WalletBadgeShowcaseProps) { + const { data, isLoading, isError, refetch } = useWalletBadges(wallet); + + const { earned, locked } = useMemo(() => splitBadges(data ?? []), [data]); + + return ( +
+

+ Badges +

+ + {isLoading ? ( +
+ {Array.from({ length: 5 }, (_, index) => ( + + ))} +
+ ) : isError ? ( +
+ Couldn't load badges. + +
+ ) : ( + <> + {earned.length === 0 && ( +
+ No badges earned yet. Complete the activities below to earn + your first badge. +
+ )} + + {earned.length > 0 && ( +
+ {earned.map(badge => { + const Icon = badge.icon; + return ( + +
+
+
+ ); + })} +
+ )} + + {locked.length > 0 && ( +
+ {locked.map(badge => ( + +
+
+
+ ))} +
+ )} + + )} +
+ ); +} diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 3ff76d6f..b9e03ae9 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -12,7 +12,11 @@ import { useKeyCostBasis } from '@/hooks/useKeyCostBasis'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { fetchWalletActivityPage } from '@/services/walletActivity.service'; -import { fetchWalletHoldings } from '@/services/wallet.service'; +import { + fetchWalletBadges, + fetchWalletHoldings, +} from '@/services/wallet.service'; +import type { WalletBadge } from '@/utils/walletBadges.utils'; import { fetchTradeHistoryPage } from '@/services/tradeHistory.service'; export function useWalletHoldings(address: string) { @@ -23,6 +27,14 @@ export function useWalletHoldings(address: string) { }); } +export function useWalletBadges(address: string) { + return useQuery({ + queryKey: queryKeys.wallet.badges(address), + queryFn: () => fetchWalletBadges(address), + enabled: !!address, + }); +} + /** * Paginated wallet activity feed. * diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index c49d3dbe..3ae122c7 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -87,6 +87,7 @@ export const queryKeys = { }, wallet: { holdings: (address: string) => ['wallet', address, 'holdings'] as const, + badges: (address: string) => ['wallet', address, 'badges'] as const, portfolioHistory: (address: string, range: string) => ['wallet', address, 'portfolioHistory', range] as const, activity: (address: string) => ['wallet', address, 'activity'] as const, diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 0ec481a7..0e089c93 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -21,6 +21,7 @@ import ProtocolRevenueClaim from '@/components/common/ProtocolRevenueClaim'; import ProtocolRevenueDistributionTable from '@/components/common/ProtocolRevenueDistributionTable'; import WalletActivityFeed from '@/components/common/WalletActivityFeed'; import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; +import WalletBadgeShowcase from '@/components/common/WalletBadgeShowcase'; import TruncatedAddress from '@/components/common/TruncatedAddress'; import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill'; import ActivePriceAlertsList from '@/components/common/ActivePriceAlertsList'; @@ -288,6 +289,10 @@ export default function ProfilePage() { isShareCopied={isShareCopied} /> + + + + {/* Tab navigation */} { + try { + const response = await this.api.get>( + `/wallets/${address}/badges` + ); + + const payload = response.data.data; + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.badges)) return payload.badges; + return []; + } catch (error) { + const apiError = this.handleError(error); + // The badges endpoint may not exist yet; treat it as no badges. + if (apiError.status === 404) return []; + throw apiError; + } + } } export const walletService = new WalletService(); @@ -31,3 +52,9 @@ export async function fetchWalletHoldings( ): Promise { return walletService.getHoldings(address); } + +export async function fetchWalletBadges( + address: string +): Promise { + return walletService.getBadges(address); +} diff --git a/src/utils/walletBadges.utils.ts b/src/utils/walletBadges.utils.ts new file mode 100644 index 00000000..52ed036e --- /dev/null +++ b/src/utils/walletBadges.utils.ts @@ -0,0 +1,84 @@ +import { Coins, Droplets, Key, Users, Vote, type LucideIcon } from 'lucide-react'; + +export type WalletBadgeType = + | 'first-key' + | 'staker' + | 'liquidity-provider' + | 'governance-voter' + | 'referrer'; + +export type WalletBadge = { + type: WalletBadgeType; + earnedAt: string; +}; + +export type BadgeDefinition = { + id: WalletBadgeType; + name: string; + criteria: string; + icon: LucideIcon; +}; + +export type EarnedBadge = BadgeDefinition & { earnedAt: string }; + +export const BADGE_CATALOG: readonly BadgeDefinition[] = [ + { + id: 'first-key', + name: 'First Key', + criteria: 'Buy your first creator key', + icon: Key, + }, + { + id: 'staker', + name: 'Staker', + criteria: 'Stake creator keys to earn rewards', + icon: Coins, + }, + { + id: 'liquidity-provider', + name: 'Liquidity Provider', + criteria: 'Add liquidity to a creator key pool', + icon: Droplets, + }, + { + id: 'governance-voter', + name: 'Governance Voter', + criteria: 'Cast a vote on a governance proposal', + icon: Vote, + }, + { + id: 'referrer', + name: 'Referrer', + criteria: 'Refer a wallet that completes its first trade', + icon: Users, + }, +]; + +/** + * Splits the catalog into earned and locked badges. Earned badges keep catalog + * order, are de-duplicated, and unknown API badge types are ignored. + */ +export function splitBadges(badges: readonly WalletBadge[]): { + earned: EarnedBadge[]; + locked: BadgeDefinition[]; +} { + const earnedAtByType = new Map(); + for (const badge of badges) { + if (!earnedAtByType.has(badge.type)) { + earnedAtByType.set(badge.type, badge.earnedAt); + } + } + + const earned: EarnedBadge[] = []; + const locked: BadgeDefinition[] = []; + for (const definition of BADGE_CATALOG) { + const earnedAt = earnedAtByType.get(definition.id); + if (earnedAt !== undefined) { + earned.push({ ...definition, earnedAt }); + } else { + locked.push(definition); + } + } + + return { earned, locked }; +}