"use client";
import { AbstractProfile } from "@/components/abstract-profile";
import { useAbstractProfileByAddress } from "@/hooks/use-abstract-profile";
import {
getTierName,
getTierColor,
} from "@/lib/tier-colors";
import { getDisplayName } from "@/lib/address-utils";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { useAccount } from "wagmi";
function PlayerCard({ address }: { address: string }) {
const { data: profile, isLoading } = useAbstractProfileByAddress(address);
if (isLoading) {
return (
<Card className="w-full">
<CardHeader>
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-3 w-16" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-20" />
</div>
</CardContent>
</Card>
);
}
const displayName = getDisplayName(profile?.user?.name || "", address);
const tier = profile?.user?.tier || 2;
const tierName = getTierName(tier);
const tierColor = getTierColor(tier);
const claimedBadges = profile?.user?.badges?.filter((b) => b.claimed) || [];
return (
<Card className="w-full gap-3">
<CardHeader>
<div className="flex items-center gap-3">
<AbstractProfile address={address} size="lg" showTooltip={false} />
<div className="flex-1 min-w-0">
<CardTitle className="truncate">{displayName}</CardTitle>
<div className="flex items-center gap-2 mt-1">
<Badge
variant="outline"
style={{ borderColor: tierColor, color: tierColor }}
>
{tierName} Tier
</Badge>
</div>
</div>
</div>
</CardHeader>
<div className="px-6">
<Separator />
</div>
<CardContent>
<div>
<h4 className="text-sm font-medium mb-2">Badges</h4>
{claimedBadges.length > 0 ? (
<div className="flex flex-wrap gap-2">
{claimedBadges.slice(0, 3).map((badgeData) => (
<Badge
key={badgeData.badge.id}
variant="secondary"
className="text-xs"
>
{badgeData.badge.name}
</Badge>
))}
{claimedBadges.length > 3 && (
<Badge variant="outline" className="text-xs">
+{claimedBadges.length - 3} more
</Badge>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">No badges yet</p>
)}
</div>
</CardContent>
</Card>
);
}
export default function AbstractProfileDemo() {
const { address: connectedAddress } = useAccount();
const defaultAddress = "0x1C67724aCc76821C8aD1f1F87BA2751631BAbD0c";
return <PlayerCard address={connectedAddress || defaultAddress} />;
}
pnpm dlx shadcn@latest add "https://build.abs.xyz/r/abstract-profile.json"
The component automatically uses the connected wallet address:
import { AbstractProfile } from "@/components/abstract-profile"
export default function AbstractProfileDemo() {
return (
<AbstractProfile />
)
}
Display a profile for a specific wallet address:
import { AbstractProfile } from "@/components/abstract-profile"
export default function CustomAddressProfile() {
return (
<AbstractProfile address="0x1C67724aCc76821C8aD1f1F87BA2751631BAbD0c" />
)
}
The component supports three size variants:
import { AbstractProfile } from "@/components/abstract-profile"
export default function ProfileSizes() {
return (
<div className="flex items-center gap-4">
<AbstractProfile size="sm" />
<AbstractProfile size="md" />
<AbstractProfile size="lg" />
</div>
)
}
Override the tier-based border color with a custom color:
import { AbstractProfile } from "@/components/abstract-profile"
export default function CustomStyledProfile() {
return (
<AbstractProfile
shineColor="#8B5CF6"
fallback="JS"
/>
)
}
Disable the tooltip that shows the display name:
import { AbstractProfile } from "@/components/abstract-profile"
export default function ProfileWithoutTooltip() {
return (
<AbstractProfile showTooltip={false} />
)
}
Access profile data directly with the provided hooks:
"use client";
import { useAbstractProfile } from "@/hooks/use-abstract-profile";
import { getTierName } from "@/lib/tier-colors";
export default function ProfileDataDemo() {
const { data: profile, isLoading, error } = useAbstractProfile();
if (error) {
return <div className="text-red-500">Failed to load profile</div>;
}
return (
<div className="space-y-6">
{/* Connected wallet profile */}
<div className="border rounded-lg p-4">
<h3 className="font-semibold mb-3">Connected Wallet Profile</h3>
{isLoading ? (
<div className="animate-pulse">Loading profile...</div>
) : profile ? (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div>
<p className="font-medium">{profile.user.name}</p>
<p className="text-sm text-muted-foreground">
{getTierName(profile.user.tier)} Tier •{" "}
{profile.user.badges.length} badges
</p>
</div>
</div>
{profile.user.description && (
<p className="text-sm">{profile.user.description}</p>
)}
</div>
) : (
<p className="text-muted-foreground">
No profile found for connected wallet
</p>
)}
</div>
</div>
);
}
This command installs the following files:
/components
abstract-profile.tsx
- The main profile component with tier-based styling/hooks
use-abstract-profile.ts
- React Query hooks for fetching profile data/lib
get-user-profile.ts
- API utility for fetching user profiles from Abstract Portaltier-colors.ts
- Tier color mapping and utilitiesaddress-utils.ts
- Address formatting utilitiesabstract-profile.tsx
A profile component that displays user profile pictures from Abstract Portal with automatic tier-based border styling, loading states, and fallback support.
"use client";
import React from "react";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { useAbstractProfileByAddress } from "@/hooks/use-abstract-profile";
import { getTierColor } from "@/lib/tier-colors";
import { getDisplayName } from "@/lib/address-utils";
import { useAccount } from "wagmi";
import { cn } from "@/lib/utils";
import { type ClassValue } from "clsx";
interface AbstractProfileProps {
address?: `0x${string}`; // Optional - defaults to connected wallet
fallback?: string; // Optional - defaults to first 2 chars of address
shineColor?: string; // Optional now, will use tier color if not provided
size?: "sm" | "md" | "lg";
showTooltip?: boolean; // Optional tooltip
className?: ClassValue;
}
/**
* A profile component that loads Abstract Portal user profiles to display:
* - User profile pictures from Abstract Portal
* - Tier-based colored borders (Bronze, Silver, Gold, Platinum, Diamond)
* - Loading states with skeleton placeholders
* - Fallback support for missing profile data
* - Responsive size variants
* - Optional tooltips with display names
*
* @param address - Optional wallet address to fetch profile for (defaults to connected wallet)
* @param fallback - Optional fallback text to display if image fails to load (defaults to first 2 chars of address)
* @param shineColor - Optional custom border color (defaults to tier color)
* @param size - Avatar size variant (sm, md, lg)
* @param showTooltip - Whether to show tooltip on hover
* @param className - Optional CSS classes to apply to the component
*/
export function AbstractProfile({
address: providedAddress,
fallback: providedFallback,
shineColor,
size = "md",
showTooltip = true,
className,
}: AbstractProfileProps) {
const {
address: connectedAddress,
isConnecting,
isReconnecting,
} = useAccount();
// Use provided address or fall back to connected wallet address
const address = providedAddress || connectedAddress;
// Generate fallback from address if not provided
const fallback =
providedFallback || (address ? address.slice(2, 4).toUpperCase() : "??");
const sizeClasses = {
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-12 w-12",
};
const { data: profile, isLoading } = useAbstractProfileByAddress(address);
// Show loading state if wallet is connecting/reconnecting or if no address available yet
if (!address || isConnecting || isReconnecting || isLoading) {
return (
<div
className={cn(`relative rounded-full ${sizeClasses[size]}`, className)}
style={{ border: `2px solid #C0C0C0` }}
>
<div className="absolute inset-0 rounded-full overflow-hidden">
<Avatar className={`w-full h-full`}>
<Skeleton className={`w-full h-full rounded-full bg-muted/50`} />
</Avatar>
</div>
</div>
);
}
const avatarSrc =
profile?.user?.overrideProfilePictureUrl ||
"https://abstract-assets.abs.xyz/avatars/1-1-1.png";
const displayName = getDisplayName(profile?.user?.name || "", address);
// Use tier-based color if shineColor not provided
const tierColor = profile?.user?.tier
? getTierColor(profile.user.tier)
: getTierColor(1);
const finalBorderColor = shineColor || tierColor;
const avatarElement = (
<div
className={cn(`relative rounded-full ${sizeClasses[size]}`, className)}
style={{ border: `2px solid ${finalBorderColor}` }}
>
<div className="absolute inset-0 rounded-full overflow-hidden">
<Avatar
className={`w-full h-full transition-transform duration-200 hover:scale-110`}
>
<AvatarImage
src={avatarSrc}
alt={`${displayName} avatar`}
className="object-cover"
/>
<AvatarFallback>{fallback}</AvatarFallback>
</Avatar>
</div>
</div>
);
if (!showTooltip) {
return avatarElement;
}
return (
<Tooltip>
<TooltipTrigger asChild>{avatarElement}</TooltipTrigger>
<TooltipContent>
<p>{displayName}</p>
</TooltipContent>
</Tooltip>
);
}
Prop | Type | Default | Description |
---|---|---|---|
address | string | undefined | Optional wallet address to display profile for (defaults to connected wallet) |
fallback | string | First 2 chars of address | Fallback text to display if image fails to load |
shineColor | string | Tier-based color | Custom border color (overrides tier color) |
size | "sm" | "md" | "lg" | "md" | Avatar size variant |
showTooltip | boolean | true | Whether to show tooltip with display name on hover |
use-abstract-profile.ts
React Query hooks for fetching Abstract Portal profile data.
"use client";
import { getUserProfile } from "@/lib/get-user-profile";
import { useQuery } from "@tanstack/react-query";
import { useAccount } from "wagmi";
/**
* Hook to retrieve the Abstract Portal profile for the current connected account
* @returns The profile data with loading and error states
*/
export function useAbstractProfile() {
const { address, isConnecting, isReconnecting } = useAccount();
const query = useQuery({
queryKey: ["abstract-profile", address],
queryFn: async () => {
if (!address) {
return null;
}
return await getUserProfile(address);
},
enabled: !!address,
staleTime: 1000 * 60 * 1, // 1 minute
refetchOnWindowFocus: false,
});
return {
...query,
isLoading: query.isLoading || isConnecting || isReconnecting,
};
}
/**
* Hook to retrieve the Abstract Portal profile for a specific address
* @param address - The address to get the profile for
* @returns The profile data with loading and error states
*/
export function useAbstractProfileByAddress(
address: `0x${string}` | undefined
) {
return useQuery({
queryKey: ["abstract-profile", address],
queryFn: async () => {
if (!address) {
return null;
}
return await getUserProfile(address);
},
enabled: !!address,
staleTime: 1000 * 60 * 5, // 5 minutes (longer for other users)
refetchOnWindowFocus: false,
retry: (failureCount, error: Error & { status?: number }) => {
// Don't retry if it's a 404 (profile doesn't exist) or similar client errors
if (error?.status && error?.status >= 400 && error?.status < 500) {
return false;
}
// Retry up to 2 times for other errors
return failureCount < 2;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
}
tier-colors.ts
Utilities for mapping Abstract Portal tier levels to their corresponding colors (Bronze, Silver, Gold, Platinum, Diamond).
/**
* Maps tier numbers to their corresponding colors
* 1 = Bronze, 2 = Silver, 3 = Gold, 4 = Platinum, 5 = Diamond
*/
export const TIER_COLORS = {
1: "#CD7F32", // Bronze
2: "#C0C0C0", // Silver
3: "#FFD700", // Gold
4: "#E5E4E2", // Platinum
5: "#B9F2FF", // Diamond
} as const;
/**
* Gets the color for a given tier number
* @param tier - The tier number (1-5)
* @returns The hex color string for the tier
*/
export function getTierColor(tier: number): string {
// Default to bronze if tier is invalid or not provided
if (!tier || tier < 1 || tier > 5) {
return TIER_COLORS[1]; // Bronze as default
}
return TIER_COLORS[tier as keyof typeof TIER_COLORS];
}
/**
* Gets the tier name from tier number
* @param tier - The tier number (1-5)
* @returns The tier name
*/
export function getTierName(tier: number): string {
const tierNames = {
1: "Bronze",
2: "Silver",
3: "Gold",
4: "Platinum",
5: "Diamond",
} as const;
if (!tier || tier < 1 || tier > 5) {
return "Bronze"; // Default
}
return tierNames[tier as keyof typeof tierNames];
}
address-utils.ts
Address formatting utilities for displaying wallet addresses in a user-friendly format.
import { isAddress } from 'viem';
/**
* Utility functions for formatting blockchain addresses
*/
/**
* Trims an Ethereum address to show only the first and last few characters
* @param address - The full Ethereum address
* @param startChars - Number of characters to show at the start (default: 6)
* @param endChars - Number of characters to show at the end (default: 4)
* @returns Trimmed address in format "0x1234...5678"
*/
export function trimAddress(
address: string,
startChars: number = 6,
endChars: number = 4
): string {
if (!address) return '';
if (address.length <= startChars + endChars) return address;
return `${address.slice(0, startChars)}...${address.slice(-endChars)}`;
}
/**
* Checks if a string is a valid Ethereum address and trims it if so
* @param nameOrAddress - The string to check (could be username or address)
* @param fallbackAddress - The actual user address to use if nameOrAddress is an address
* @returns Trimmed address if nameOrAddress is an address, otherwise returns nameOrAddress
*/
export function getDisplayName(nameOrAddress: string, fallbackAddress?: string): string {
if (!nameOrAddress) return fallbackAddress ? trimAddress(fallbackAddress) : 'anon';
// If the name is actually an address, trim it
if (isAddress(nameOrAddress)) {
return trimAddress(nameOrAddress);
}
// Otherwise, it's a real username, return as-is
return nameOrAddress;
}
get-user-profile.ts
API utility function for fetching user profile data from the Abstract Portal API.
/**
* The profile information returned from the Abstract Portal API
*/
export type AbstractPortalProfile = {
user: {
id: string;
name: string;
description: string;
walletAddress: string;
avatar: {
assetType: string;
tier: number;
key: number;
season: number;
};
banner: {
assetType: string;
tier: number;
key: number;
season: number;
};
tier: number;
hasCompletedWelcomeTour: boolean;
hasStreamingAccess: boolean;
overrideProfilePictureUrl: string;
lastTierSeen: number;
metadata: {
lastTierSeen: number;
lastUpgradeSeen: number;
hasCompletedWelcomeTour: boolean;
};
badges: {
badge: {
id: number;
type: string;
name: string;
icon: string;
description: string;
requirement: string;
url?: string;
timeStart?: number;
timeEnd?: number;
};
claimed: boolean;
}[];
verification: string;
};
};
/**
* Get the profile information of an Abstract Global Wallet given the wallet address
* @param walletAddress - The wallet address to get the profile for
* @returns The profile information
*/
export async function getUserProfile(walletAddress: string): Promise<AbstractPortalProfile> {
const response = await fetch(`/api/user-profile/${walletAddress}`, {
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = new Error(`HTTP error! status: ${response.status}`) as Error & { status?: number };
error.status = response.status;
throw error;
}
return response.json();
}