0

Abstract Profile

PreviousNext

A profile component that displays user profile pictures from Abstract Portal with tier-based styling and loading states

Installation

pnpm dlx shadcn@latest add "https://build.abs.xyz/r/abstract-profile.json"

Usage

The component automatically uses the connected wallet address:

import { AbstractProfile } from "@/components/abstract-profile"
 
export default function AbstractProfileDemo() {
  return (
    <AbstractProfile />
  )
}

Custom Address

Display a profile for a specific wallet address:

import { AbstractProfile } from "@/components/abstract-profile"
 
export default function CustomAddressProfile() {
  return (
    <AbstractProfile address="0x1C67724aCc76821C8aD1f1F87BA2751631BAbD0c" />
  )
}

Size Variants

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>
  )
}

Custom Styling

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" 
    />
  )
}

Without Tooltip

Disable the tooltip that shows the display name:

import { AbstractProfile } from "@/components/abstract-profile"
 
export default function ProfileWithoutTooltip() {
  return (
    <AbstractProfile showTooltip={false} />
  )
}

Using Hooks Directly

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>
  );
}

What's included

This command installs the following files:

abstract-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>
  );
}

Props

PropTypeDefaultDescription
addressstringundefinedOptional wallet address to display profile for (defaults to connected wallet)
fallbackstringFirst 2 chars of addressFallback text to display if image fails to load
shineColorstringTier-based colorCustom border color (overrides tier color)
size"sm" | "md" | "lg""md"Avatar size variant
showTooltipbooleantrueWhether 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();
}