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 axios2. 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
| Endpoint | Method | Description |
|---|---|---|
/v1/wallet/balances?user_id= | GET | Get all balances (tenant from JWT) |
/v1/wallet/transactions?user_id= | GET | Get transaction history |
/v1/wallet/tier?user_id= | GET | Get 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
Querying Wallets
This guide explains how users can check their wallet balances, view transaction history, and see their loyalty tier status.
Rewards
Rewards are the tangible prizes users receive for achievements, milestones, and competition victories. Phoenix separates what you give (Reward Items) from when you give it (Reward Configs and feature triggers).