Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/components/common/WalletBadgeShowcase.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
data-testid="wallet-badge-showcase"
aria-labelledby="wallet-badges-heading"
className={cn('space-y-3', className)}
>
<h2
id="wallet-badges-heading"
className="text-lg font-semibold text-white"
>
Badges
</h2>

{isLoading ? (
<div className={GRID_CLASS}>
{Array.from({ length: 5 }, (_, index) => (
<Skeleton key={index} className="h-28 w-full" />
))}
</div>
) : isError ? (
<div className="flex items-center gap-3 rounded-lg border border-white/10 p-4 text-sm text-white/70">
<span>Couldn't load badges.</span>
<button
type="button"
onClick={() => void refetch()}
className="rounded-md border border-white/20 px-3 py-1 text-white hover:bg-white/10"
>
Retry
</button>
</div>
) : (
<>
{earned.length === 0 && (
<div
data-testid="wallet-badge-empty-state"
className="rounded-lg border border-dashed border-white/15 p-4 text-sm text-white/70"
>
No badges earned yet. Complete the activities below to earn
your first badge.
</div>
)}

{earned.length > 0 && (
<div className={GRID_CLASS}>
{earned.map(badge => {
const Icon = badge.icon;
return (
<Tooltip key={badge.id} content={badge.criteria}>
<div
tabIndex={0}
data-testid={`wallet-badge-earned-${badge.id}`}
className="flex w-full flex-col items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] p-4 text-center focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
>
<Icon className="h-8 w-8 text-white" aria-hidden="true" />
<span className="text-sm font-medium text-white">
{badge.name}
</span>
<span className="text-xs text-white/60">
{formatEarnedDate(badge.earnedAt)}
</span>
</div>
</Tooltip>
);
})}
</div>
)}

{locked.length > 0 && (
<div className={GRID_CLASS}>
{locked.map(badge => (
<Tooltip key={badge.id} content={badge.criteria}>
<div
tabIndex={0}
data-testid={`wallet-badge-locked-${badge.id}`}
className="flex w-full flex-col items-center gap-2 rounded-lg border border-white/10 bg-white/[0.02] p-4 text-center opacity-60 grayscale focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
>
<Lock
className="h-8 w-8 text-white/60"
aria-hidden="true"
/>
<span className="text-sm font-medium text-white/80">
{badge.name}
</span>
<span className="text-xs text-white/60">
{badge.criteria}
</span>
<span className="text-[10px] uppercase tracking-wide text-white/50">
Locked
</span>
</div>
</Tooltip>
))}
</div>
)}
</>
)}
</section>
);
}
14 changes: 13 additions & 1 deletion src/hooks/useWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -23,6 +27,14 @@ export function useWalletHoldings(address: string) {
});
}

export function useWalletBadges(address: string) {
return useQuery<WalletBadge[]>({
queryKey: queryKeys.wallet.badges(address),
queryFn: () => fetchWalletBadges(address),
enabled: !!address,
});
}

/**
* Paginated wallet activity feed.
*
Expand Down
1 change: 1 addition & 0 deletions src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/pages/ProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -288,6 +289,10 @@ export default function ProfilePage() {
isShareCopied={isShareCopied}
/>

<SectionErrorBoundary sectionName="badges">
<WalletBadgeShowcase wallet={profileWallet} />
</SectionErrorBoundary>

{/* Tab navigation */}
<ProfileTabPillGroup
tabs={TABS}
Expand Down
27 changes: 27 additions & 0 deletions src/services/wallet.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { BaseApiService, type APIResponse } from './api.service';
import type { HeldKeyPosition } from '@/utils/portfolioValue.utils';
import type { WalletBadge } from '@/utils/walletBadges.utils';

export type WalletHolding = HeldKeyPosition;

type BadgesPayload = WalletBadge[] | { badges?: WalletBadge[] };

type HoldingsPayload =
WalletHolding[] | { holdings?: WalletHolding[]; data?: WalletHolding[] };

Expand All @@ -22,6 +25,24 @@ class WalletService extends BaseApiService {
throw this.handleError(error);
}
}

async getBadges(address: string): Promise<WalletBadge[]> {
try {
const response = await this.api.get<APIResponse<BadgesPayload>>(
`/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();
Expand All @@ -31,3 +52,9 @@ export async function fetchWalletHoldings(
): Promise<WalletHolding[]> {
return walletService.getHoldings(address);
}

export async function fetchWalletBadges(
address: string
): Promise<WalletBadge[]> {
return walletService.getBadges(address);
}
84 changes: 84 additions & 0 deletions src/utils/walletBadges.utils.ts
Original file line number Diff line number Diff line change
@@ -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<WalletBadgeType, string>();
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 };
}
Loading