Phoenix Gamification
Wallets

Wallet Frontend Integration Guide

A practical guide for frontend developers integrating the Phoenix Wallet system into web and mobile applications.

Wallet Frontend Integration Guide

A practical guide for frontend developers integrating the Phoenix Wallet system into web and mobile applications.

Quick Start

1. Install Dependencies

npm install jwt-decode axios
# or
yarn add jwt-decode axios

2. Create the Wallet Client

// src/services/wallet.ts
import axios, { AxiosInstance } from 'axios';

interface Balance {
  currency_id: string;
  currency_name: string;
  currency_symbol: string | null;
  available: number;
  lifetime_earned: number;
}

interface Transaction {
  id: string;
  currency_id: string;
  amount: number;
  balance_after: number;
  source_type: string;
  source_ref: string | null;
  description: string | null;
  created_at: string;
}

interface UserTier {
  tier_id: string;
  tier_name: string;
  tier_level: number;
  lifetime_points: number;
  next_tier_name: string | null;
  next_tier_points: number | null;
  points_to_next: number | null;
}

interface UserBalances {
  tenant_id: string;
  user_id: string;
  balances: Balance[];
}

export class WalletClient {
  private api: AxiosInstance;
  private tenantId: string;
  private userId: string;

  constructor(baseUrl: string, tenantId: string, userId: string, jwt: string) {
    this.tenantId = tenantId;
    this.userId = userId;
    this.api = axios.create({
      baseURL: baseUrl,
      headers: {
        'Authorization': `Bearer ${jwt}`,
        'Content-Type': 'application/json'
      }
    });
  }

  async getBalances(): Promise<UserBalances> {
    const { data } = await this.api.get(
      `/v1/wallet/balances`,
      { params: { user_id: this.userId } }
    );
    return data;
  }

  async getBalance(currencyId: string): Promise<Balance | null> {
    const { balances } = await this.getBalances();
    return balances.find(b => b.currency_id === currencyId) || null;
  }

  async getTransactions(options?: {
    currency_id?: string;
    limit?: number;
    offset?: number;
  }): Promise<Transaction[]> {
    const { data } = await this.api.get(
      `/v1/wallet/transactions`,
      { 
        params: { 
          user_id: this.userId,
          ...options 
        } 
      }
    );
    return data;
  }

  async getTier(): Promise<UserTier | null> {
    const { data } = await this.api.get(
      `/v1/wallet/tier`,
      { params: { user_id: this.userId } }
    );
    return data;
  }
}

3. Initialize in Your App

// src/contexts/WalletContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import { WalletClient, Balance, UserTier } from '../services/wallet';

interface WalletState {
  balances: Balance[];
  tier: UserTier | null;
  loading: boolean;
  error: string | null;
  refresh: () => Promise<void>;
  getBalance: (currencyId: string) => number;
}

const WalletContext = createContext<WalletState | null>(null);

export function WalletProvider({ children, jwt, userId, tenantId }) {
  const [balances, setBalances] = useState<Balance[]>([]);
  const [tier, setTier] = useState<UserTier | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const client = new WalletClient(
    process.env.REACT_APP_QUERY_API_URL!,
    tenantId,
    userId,
    jwt
  );

  const refresh = async () => {
    try {
      setLoading(true);
      setError(null);
      
      const [balanceData, tierData] = await Promise.all([
        client.getBalances(),
        client.getTier()
      ]);
      
      setBalances(balanceData.balances);
      setTier(tierData);
    } catch (err) {
      setError('Failed to load wallet data');
      console.error(err);
    } finally {
      setLoading(false);
    }
  };

  const getBalance = (currencyId: string): number => {
    return balances.find(b => b.currency_id === currencyId)?.available || 0;
  };

  useEffect(() => {
    refresh();
  }, [userId]);

  return (
    <WalletContext.Provider value={{ balances, tier, loading, error, refresh, getBalance }}>
      {children}
    </WalletContext.Provider>
  );
}

export const useWallet = () => {
  const context = useContext(WalletContext);
  if (!context) throw new Error('useWallet must be used within WalletProvider');
  return context;
};

UI Components

Balance Display

// src/components/BalanceDisplay.tsx
import React from 'react';
import { useWallet } from '../contexts/WalletContext';

interface BalanceDisplayProps {
  currencyId: string;
  showLifetime?: boolean;
  size?: 'sm' | 'md' | 'lg';
}

export function BalanceDisplay({ 
  currencyId, 
  showLifetime = false,
  size = 'md' 
}: BalanceDisplayProps) {
  const { balances, loading } = useWallet();
  
  const balance = balances.find(b => b.currency_id === currencyId);
  
  if (loading) {
    return <span className="balance-skeleton">...</span>;
  }
  
  if (!balance) {
    return <span className="balance-zero">0</span>;
  }

  const sizeClasses = {
    sm: 'text-sm',
    md: 'text-base',
    lg: 'text-2xl font-bold'
  };

  return (
    <div className={`balance-display ${sizeClasses[size]}`}>
      <span className="balance-symbol">{balance.currency_symbol || ''}</span>
      <span className="balance-amount">
        {balance.available.toLocaleString()}
      </span>
      <span className="balance-name">{balance.currency_name}</span>
      
      {showLifetime && (
        <span className="balance-lifetime text-sm text-gray-500">
          ({balance.lifetime_earned.toLocaleString()} lifetime)
        </span>
      )}
    </div>
  );
}

Multi-Currency Wallet Card

// src/components/WalletCard.tsx
import React from 'react';
import { useWallet } from '../contexts/WalletContext';

export function WalletCard() {
  const { balances, loading, refresh } = useWallet();

  if (loading) {
    return (
      <div className="wallet-card animate-pulse">
        <div className="h-6 bg-gray-200 rounded w-24 mb-4"></div>
        <div className="space-y-3">
          <div className="h-4 bg-gray-200 rounded"></div>
          <div className="h-4 bg-gray-200 rounded w-3/4"></div>
        </div>
      </div>
    );
  }

  return (
    <div className="wallet-card bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl p-6 text-white shadow-lg">
      <div className="flex justify-between items-center mb-4">
        <h3 className="text-lg font-semibold">My Wallet</h3>
        <button 
          onClick={refresh}
          className="p-2 hover:bg-white/10 rounded-full transition"
          aria-label="Refresh balances"
        >
          🔄
        </button>
      </div>
      
      <div className="space-y-3">
        {balances.length === 0 ? (
          <p className="text-white/70">No currencies yet</p>
        ) : (
          balances.map(balance => (
            <div 
              key={balance.currency_id}
              className="flex justify-between items-center bg-white/10 rounded-lg p-3"
            >
              <div className="flex items-center gap-2">
                <span className="text-2xl">{balance.currency_symbol || '💰'}</span>
                <span className="font-medium">{balance.currency_name}</span>
              </div>
              <div className="text-right">
                <div className="text-xl font-bold">
                  {balance.available.toLocaleString()}
                </div>
                <div className="text-xs text-white/60">
                  {balance.lifetime_earned.toLocaleString()} lifetime
                </div>
              </div>
            </div>
          ))
        )}
      </div>
    </div>
  );
}

Loyalty Tier Progress

// src/components/TierProgress.tsx
import React from 'react';
import { useWallet } from '../contexts/WalletContext';

const TIER_COLORS: Record<string, string> = {
  'Bronze': 'from-amber-600 to-amber-800',
  'Silver': 'from-gray-400 to-gray-600',
  'Gold': 'from-yellow-400 to-yellow-600',
  'Platinum': 'from-cyan-400 to-blue-600',
  'Diamond': 'from-purple-400 to-pink-600'
};

export function TierProgress() {
  const { tier, loading } = useWallet();

  if (loading) {
    return (
      <div className="tier-progress animate-pulse">
        <div className="h-20 bg-gray-200 rounded-xl"></div>
      </div>
    );
  }

  if (!tier) {
    return (
      <div className="tier-progress bg-gray-100 rounded-xl p-4 text-center">
        <p className="text-gray-500">Start earning to unlock tiers!</p>
      </div>
    );
  }

  const progressPercent = tier.next_tier_points 
    ? Math.min(100, (tier.lifetime_points / tier.next_tier_points) * 100)
    : 100;

  const tierColor = TIER_COLORS[tier.tier_name] || 'from-gray-400 to-gray-600';

  return (
    <div className={`tier-progress bg-gradient-to-r ${tierColor} rounded-xl p-6 text-white`}>
      <div className="flex justify-between items-start mb-4">
        <div>
          <div className="text-sm opacity-80">Current Tier</div>
          <div className="text-2xl font-bold">{tier.tier_name}</div>
        </div>
        <div className="text-right">
          <div className="text-sm opacity-80">Lifetime Points</div>
          <div className="text-xl font-semibold">
            {tier.lifetime_points.toLocaleString()}
          </div>
        </div>
      </div>

      {tier.next_tier_name && (
        <>
          <div className="mb-2 flex justify-between text-sm">
            <span>Progress to {tier.next_tier_name}</span>
            <span>{tier.points_to_next?.toLocaleString()} points to go</span>
          </div>
          <div className="h-3 bg-white/30 rounded-full overflow-hidden">
            <div 
              className="h-full bg-white rounded-full transition-all duration-500"
              style={{ width: `${progressPercent}%` }}
            />
          </div>
        </>
      )}

      {!tier.next_tier_name && (
        <div className="text-center mt-2 text-sm opacity-80">
          🎉 You've reached the highest tier!
        </div>
      )}
    </div>
  );
}

Transaction History

// src/components/TransactionHistory.tsx
import React, { useState, useEffect } from 'react';
import { useWallet } from '../contexts/WalletContext';

interface TransactionHistoryProps {
  currencyId?: string;
  limit?: number;
}

export function TransactionHistory({ currencyId, limit = 20 }: TransactionHistoryProps) {
  const [transactions, setTransactions] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    loadTransactions();
  }, [currencyId]);

  const loadTransactions = async () => {
    setLoading(true);
    try {
      // Example: const data = await walletClient.getTransactions({ currency_id: currencyId, limit });
      const data: any[] = [];
      setTransactions(data);
    } catch (err) {
      console.error('Failed to load transactions', err);
    } finally {
      setLoading(false);
    }
  };

  const formatDate = (dateStr: string) => {
    return new Date(dateStr).toLocaleDateString('en-US', {
      month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
    });
  };

  const getSourceLabel = (sourceType: string) => {
    const labels: Record<string, string> = {
      'earning_rule': '📈 Earning Rule', 'admin': '👤 Admin Grant',
      'streak_milestone': 'đŸ”Ĩ Streak Bonus', 'coupon': 'đŸŽŸī¸ Coupon',
      'wheel_spin': '🎡 Wheel Spin', 'leaderboard': '🏆 Leaderboard',
      'purchase': '🛒 Purchase', 'refund': 'â†Šī¸ Refund'
    };
    return labels[sourceType] || sourceType;
  };

  if (loading) return <div className="animate-pulse">Loading transactions...</div>;

  return (
    <div className="transaction-history">
      <h3 className="text-lg font-semibold mb-4">Transaction History</h3>
      {transactions.length === 0 ? (
        <p className="text-gray-500 text-center py-8">No transactions yet</p>
      ) : (
        <div className="space-y-2">
          {transactions.map(txn => (
            <div key={txn.id} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
              <div>
                <div className="font-medium">{getSourceLabel(txn.source_type)}</div>
                <div className="text-sm text-gray-500">{txn.description || formatDate(txn.created_at)}</div>
              </div>
              <div className={`font-bold ${txn.amount > 0 ? 'text-green-600' : 'text-red-600'}`}>
                {txn.amount > 0 ? '+' : ''}{txn.amount.toLocaleString()}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Common Patterns

Checking Balance Before Action

// Before spending (e.g., wheel spin, purchase)
function SpinButton({ wheelId, spinCost }) {
  const { getBalance, refresh } = useWallet();
  const [spinning, setSpinning] = useState(false);

  const handleSpin = async () => {
    const balance = getBalance('gold_coins');
    
    if (balance < spinCost) {
      toast.error(`Not enough coins! Need ${spinCost}, have ${balance}`);
      return;
    }

    setSpinning(true);
    try {
      const result = await spinWheel(wheelId);
      await refresh();
      showSpinResult(result);
    } catch (err) {
      toast.error('Spin failed');
    } finally {
      setSpinning(false);
    }
  };

  return (
    <button 
      onClick={handleSpin}
      disabled={spinning || getBalance('gold_coins') < spinCost}
      className="spin-button"
    >
      {spinning ? 'Spinning...' : `Spin (${spinCost} coins)`}
    </button>
  );
}

API Reference Quick Reference

EndpointMethodDescription
/v1/wallet/balances?user_id=GETGet all balances (tenant from JWT)
/v1/wallet/transactions?user_id=GETGet transaction history
/v1/wallet/tier?user_id=GETGet tier info

All endpoints require JWT authentication in the Authorization: Bearer {token} header.

For detailed API documentation, see Wallet Overview.


Best Practices Checklist

Display

  • Show loading skeletons while fetching data
  • Handle empty states gracefully ("No transactions yet")
  • Use appropriate number formatting (locale-aware)
  • Animate balance changes for visual feedback
  • Display currency symbols/icons for recognition

UX

  • Refresh wallet after any earning/spending action
  • Show clear insufficient balance errors before actions
  • Add pull-to-refresh on mobile
  • Refresh on window/app focus
  • Consider optimistic UI updates

Performance

  • Cache wallet data in context/state
  • Debounce rapid refresh calls
  • Lazy load transaction history
  • Paginate long transaction lists

Error Handling

  • Wrap wallet components in error boundaries
  • Handle 401 (auth expired) gracefully
  • Show user-friendly error messages
  • Provide retry mechanisms

Accessibility

  • Use semantic HTML (buttons, not divs)
  • Add aria-labels to icon-only buttons
  • Ensure sufficient color contrast
  • Support screen readers

On this page