Skip to main content

Overview

SFLUV uses React Context for global state management:
  1. AppProvider - Authentication, user data, wallet management
  2. LocationProvider - Merchant locations and map state
  3. ContactsProvider - User contact book
  4. TransactionProvider - Transaction history
Location: frontend/context/

AppProvider

The primary context provider for authentication and user state. Location: frontend/context/AppProvider.tsx

Context Shape

interface AppContextType {
  // Authentication
  status: "loading" | "authenticated" | "unauthenticated"
  user: User | null
  login: () => Promise<void>
  logout: () => Promise<void>
  authFetch: (endpoint: string, options?: RequestInit) => Promise<Response>
  
  // Role-specific data
  affiliate: Affiliate | null
  proposer: Proposer | null
  improver: Improver | null
  issuer: IssuerRecord | null
  supervisor: Supervisor | null
  
  // Wallets
  wallets: AppWallet[]
  walletsStatus: "loading" | "available" | "unavailable"
  addWallet: (walletName: string) => Promise<void>
  importWallet: (walletName: string, privateKey: string) => Promise<void>
  updateWallet: (id: number, name: string) => Promise<string | null>
  refreshWallets: () => Promise<void>
  
  // User locations
  userLocations: AuthedLocation[]
  setUserLocations: Dispatch<SetStateAction<AuthedLocation[]>>
  
  // Ponder subscriptions
  ponderSubscriptions: PonderSubscription[]
  addPonderSubscription: (email: string, address: string) => Promise<void>
  getPonderSubscriptions: () => Promise<void>
  deletePonderSubscription: (id: number) => Promise<void>
  
  // Error handling
  error: string | unknown | null
  setError: Dispatch<unknown>
}

User Type

export interface User {
  id: string                    // Privy DID
  name: string
  contact_email?: string
  contact_phone?: string
  isAdmin: boolean
  isMerchant: boolean
  isOrganizer: boolean
  isImprover: boolean
  isProposer: boolean
  isVoter: boolean
  isIssuer: boolean
  isSupervisor: boolean
  isAffiliate: boolean
  paypalEthAddress: string
  lastRedemption: number
}

Usage in Components

import { useApp } from '@/context/AppProvider'

export default function MyComponent() {
  const { user, status, authFetch, wallets } = useApp()
  
  if (status === 'loading') {
    return <div>Loading...</div>
  }
  
  if (status === 'unauthenticated') {
    return <div>Please log in</div>
  }
  
  return (
    <div>
      <h1>Welcome, {user?.name}</h1>
      <p>Wallets: {wallets.length}</p>
      {user?.isProposer && <p>You are a proposer</p>}
    </div>
  )
}

Authentication Flow

1. Privy Integration

frontend/context/AppProvider.tsx:137-148
const {
  getAccessToken,
  authenticated: privyAuthenticated,
  ready: privyReady,
  login: privyLogin,
  logout: privyLogout,
  user: privyUser
} = usePrivy()

const { wallets: privyWallets, ready: walletsReady } = useWallets()

2. Auto-Login Effect

frontend/context/AppProvider.tsx:217-237
useEffect(() => {
  if (!privyReady) return
  if (!walletsReady) return
  
  // Skip auth for faucet pages
  if (pathname.startsWith("/faucet")) {
    _resetAppState()
    return
  }
  
  if (!privyAuthenticated) {
    _resetAppState()
    return
  }
  
  // User is authenticated - fetch data
  _userLogin()
}, [privyReady, privyAuthenticated, walletsReady, privyUser])

3. User Login

frontend/context/AppProvider.tsx:284-313
const _userLogin = async () => {
  if (status === "authenticated") return
  
  setStatus("loading")
  
  try {
    // 1. Fetch user from backend
    let userResponse = await _getUser()
    
    // 2. If user doesn't exist, create account
    if (userResponse === null) {
      await _postUser()
      userResponse = await _getUser()
    }
    
    if (userResponse === null) {
      throw new Error("error posting user")
    }
    
    // 3. Set user and role data
    await _userResponseToUser(userResponse)
    
    // 4. Initialize wallets
    await _initWallets(userResponse.wallets)
    
    // 5. Fetch ponder subscriptions
    await getPonderSubscriptions()
    
    // 6. Set user locations
    setUserLocations(userResponse.locations)
    
    setStatus("authenticated")
  } catch (error) {
    setError(error)
    await logout()
  }
}

4. authFetch Utility

frontend/context/AppProvider.tsx:330-339
const authFetch = async (
  endpoint: string, 
  options: RequestInit = {}
): Promise<Response> => {
  const accessToken = await getAccessToken()
  if (!accessToken) throw new Error("no access token")
  
  const headers: HeadersInit = {
    ...options.headers,
    "Access-Token": accessToken,
  }
  
  return await fetch(BACKEND + endpoint, { ...options, headers })
}
Usage:
const { authFetch } = useApp()

// GET request
const res = await authFetch('/users')
const user = await res.json()

// POST request
const res = await authFetch('/proposers/workflows', {
  method: 'POST',
  body: JSON.stringify({ title: "New Workflow", ... })
})

Wallet Management

Wallet Initialization

frontend/context/AppProvider.tsx:394-450
const _initWallets = async (extWallets?: WalletResponse[]) => {
  setWalletsStatus("loading")
  
  try {
    if (!privyUser?.id) throw new Error("user not authenticated")
    
    // Get Privy-managed wallets (embedded + linked)
    const managedPrivyWallets = getManagedPrivyWallets()
    
    let walletPromises: Promise<AppWallet>[] = []
    
    for (const privyWallet of managedPrivyWallets) {
      // Switch wallet to correct chain
      await privyWallet.switchChain(CHAIN_ID)
      
      // Initialize EOA wallet
      let extWallet = extWallets.find(
        w => w.eoa_address === privyWallet.address && w.is_eoa
      )
      walletPromises.push(_initEOAWallet(privyWallet, extWallet, i))
      
      // Initialize smart wallets
      let smartWallets = extWallets.filter(
        w => w.eoa_address === privyWallet.address && !w.is_eoa
      )
      
      for (const extSmartWallet of smartWallets) {
        walletPromises.push(_initSmartWallet(privyWallet, extSmartWallet, index, i))
      }
    }
    
    let wallets = await Promise.all(walletPromises)
    setWallets(wallets)
    setWalletsStatus("available")
  } catch (error) {
    setWalletsStatus("unavailable")
    throw new Error("error initializing wallets")
  }
}

Adding a Smart Wallet

const { addWallet } = useApp()

await addWallet("My Spending Wallet")
// Creates a new smart account, saves to backend, adds to state

Importing a Wallet

const { importWallet } = useApp()

await importWallet("Imported Wallet", "0xprivatekey...")
// Imports EOA via Privy, saves to backend, refreshes wallet list

Idle Timer

Auto-logout after inactivity. frontend/context/AppProvider.tsx:186-208
const { getRemainingTime, start, pause, reset } = useIdleTimer({
  onIdle: () => {
    if (status === "authenticated") {
      logout()
    }
  },
  onPrompt: () => {
    if (status === "authenticated") {
      setIdleModalOpen(true)  // Show warning modal
    }
  },
  promptBeforeIdle: IDLE_TIMER_PROMPT_SECONDS * 1000,  // 60s
  timeout: IDLE_TIMER_SECONDS * 1000,                  // 600s (10min)
  throttle: 500,
  startManually: true
})

// Start timer on authentication
useEffect(() => {
  if (status === "authenticated") {
    reset()
    start()
  } else {
    pause()
  }
}, [status])

LocationProvider

Manages merchant locations and map state. Location: frontend/context/LocationProvider.tsx

Context Shape

interface LocationContextType {
  mapLocations: Location[]                    // Public locations
  authedMapLocations: AuthedLocation[]        // Admin view (includes pending)
  locationTypes: string[]                     // Categories (for filtering)
  mapLocationsStatus: "loading" | "available" | "unavailable"
  getMapLocations: () => Promise<void>
  getAuthedMapLocations: () => Promise<void>
  updateLocation: (location: AuthedLocation) => Promise<void>
  updateLocationApproval: (req: UpdateLocationApprovalRequest) => Promise<void>
  addLocation: (location: AuthedLocation) => Promise<void>
}

Usage

import { useLocation } from '@/context/LocationProvider'

export default function MapPage() {
  const { mapLocations, mapLocationsStatus, getMapLocations } = useLocation()
  
  useEffect(() => {
    getMapLocations()
  }, [])
  
  if (mapLocationsStatus === 'loading') {
    return <div>Loading map...</div>
  }
  
  return (
    <Map locations={mapLocations} />
  )
}

Adding a Location

frontend/context/LocationProvider.tsx:105-116
const addLocation = async (location: AuthedLocation) => {
  setMapLocationsStatus("loading")
  
  try {
    await _addLocation(location)  // POST to backend
    setUserLocations([...userLocations, location])
  } catch {
    setMapLocationsStatus("unavailable")
    console.error("error adding new location")
  }
  
  setMapLocationsStatus("available")
}

ContactsProvider

Manages user contact book. Location: frontend/context/ContactsProvider.tsx

Context Shape

interface ContactsContextType {
  contacts: Contact[]
  getContacts: () => Promise<void>
  addContact: (contact: Contact) => Promise<void>
  updateContact: (contact: Contact) => Promise<void>
  deleteContact: (id: number) => Promise<void>
}

Usage

import { useContacts } from '@/context/ContactsProvider'

const { contacts, addContact } = useContacts()

const handleAddContact = async () => {
  await addContact({
    name: "Alice",
    address: "0x...",
    email: "[email protected]"
  })
}

Root Provider

Location: frontend/context/Providers.tsx Wraps all context providers:
import { PrivyProvider } from '@privy-io/react-auth'
import AppProvider from './AppProvider'
import LocationProvider from './LocationProvider'
import ContactsProvider from './ContactsProvider'
import TransactionProvider from './TransactionProvider'

export default function Providers({ children }: { children: ReactNode }) {
  return (
    <PrivyProvider
      appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID!}
      config={{
        loginMethods: ['email', 'sms', 'wallet', 'google'],
        appearance: { theme: 'light' },
        embeddedWallets: { createOnLogin: 'users-without-wallets' }
      }}
    >
      <AppProvider>
        <LocationProvider>
          <ContactsProvider>
            <TransactionProvider>
              {children}
            </TransactionProvider>
          </ContactsProvider>
        </LocationProvider>
      </AppProvider>
    </PrivyProvider>
  )
}

Usage in Layout

frontend/app/layout.tsx
import Providers from '@/context/Providers'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  )
}

Best Practices

1. Always Check Status

const { status, user } = useApp()

if (status === 'loading') return <Spinner />
if (status === 'unauthenticated') return <LoginPrompt />

// Safe to use user
return <div>Welcome {user.name}</div>

2. Use authFetch for API Calls

const { authFetch } = useApp()

// Good
const res = await authFetch('/workflows')

// Bad (missing JWT)
const res = await fetch(`${BACKEND}/workflows`)

3. Handle Errors Gracefully

const { authFetch, setError } = useApp()

try {
  const res = await authFetch('/workflows', {
    method: 'POST',
    body: JSON.stringify(data)
  })
  if (!res.ok) throw new Error('Failed to create workflow')
} catch (error) {
  setError(error)
  // Error is logged in AppProvider
}

4. Role-Based Rendering

const { user } = useApp()

return (
  <div>
    {user?.isAdmin && <AdminPanel />}
    {user?.isProposer && <WorkflowBuilder />}
    {user?.isImprover && <WorkflowFeed />}
  </div>
)

Next Steps

Components

Reusable component patterns

Frontend Overview

Next.js app structure

Build docs developers (and LLMs) love