Developer Hub
🔮 For applications
Guides & Tutorials
Tutorial: build your dApp
Step 7: Allow Users to View and Redeem Bets

Allow Users to View and Redeem Bets

Helping components

BetCard

Let's create bet view to render user bets. Create BetCard.tsx in the src/components folder.

Because of limitations, graphql data doesn't have full range of statuses (like "Pending resolution"). But SDK has helpers and we will map statuses. Each bet may contain more than one selection (combo bets), so we'll make universal view for single and combo bets wil redeeming logic, for that purpose SDK has useRedeemBet hook.

src/components/BetCard.tsx
'use client'
import { type Bet, useChain, useRedeemBet } from '@azuro-org/sdk'
import { getBetStatus, getGameStatus, BetStatus, GameStatus } from '@azuro-org/toolkit'
import dayjs from 'dayjs'
import Link from 'next/link'
import { useMemo } from 'react'
import cx from 'clsx'
 
 
const BetStatusText = {
  [BetStatus.Accepted]: 'Accepted',
  [BetStatus.Canceled]: 'Canceled',
  [BetStatus.Live]: 'Live',
  [BetStatus.PendingResolution]: 'Pending resolution',
  [BetStatus.Resolved]: 'Resolved',
}
 
const GameStatusText = {
  [GameStatus.Canceled]: 'Canceled',
  [GameStatus.Live]: 'Live',
  [GameStatus.Paused]: 'Paused',
  [GameStatus.PendingResolution]: 'Pending resolution',
  [GameStatus.Created]: 'Preparing',
  [GameStatus.Resolved]: 'Resolved',
}
 
type Props = {
  bet: Bet
}
 
export function BetCard(props: Props) {
  const { bet } = props
  const {
    createdAt, status: graphBetStatus, amount, outcomes,
    payout, possibleWin, freebetId,
    isWin, isCanceled, isRedeemed, isLive
  } = bet
 
  const { betToken } = useChain()
  const { submit, isPending, isProcessing } = useRedeemBet()
 
  const betStatus = useMemo(() => {
    return getBetStatus({
      graphStatus: graphBetStatus,
      games: outcomes.map(({ game }) => game),
      isLiveBet: isLive,
    })
  }, [])
 
  const isDisabled = isPending || isProcessing
 
  let winAmount
  let buttonTitle
 
  if (isCanceled) {
    winAmount = '––'
    buttonTitle = 'Refund'
  }
  else {
    winAmount = `${isWin ? '+' : ''}${possibleWin} ${betToken.symbol}`
    buttonTitle = 'Redeem'
  }
 
  if (isPending) {
    buttonTitle = 'Waiting for approval'
  }
  else if (isProcessing) {
    buttonTitle = 'Processing...'
  }
 
  const handleRedeem = async () => {
    try {
      await submit({ bets: [ bet ] })
    } catch {}
  }
 
  return (
    <div className="p-4 bg-zinc-50 mt-2 first-of-type:mt-0 rounded-lg">
      <div className="flex items-center justify-between">
        <p>{dayjs(+createdAt * 1000).format('DD.MM.YYYY, hh:mm A')}</p>
        <p>{BetStatusText[betStatus]}</p>
      </div>
      {
        outcomes.map((outcome) => {
          const { odds, marketName, game, selectionName, isWin, isLose } = outcome
 
          const { status: gameStatus, gameId, participants, startsAt, sport: { slug: sportSlug }, league } = game
          const { name: leagueName, country: { name: countryName } } = league
 
          const className = cx("mt-4 p-4 rounded-md", {
            'bg-zinc-200': !isWin && !isLose,
            'bg-green-100': isWin,
            'bg-red-100': isLose,
          })
 
          return (
            <div key={gameId} className={className}>
              <div className="flex items-center justify-between flex-wrap">
                <div className="flex items-center flex-wrap">
                  <p className='mr-4'>{dayjs(+startsAt * 1000).format('DD.MM.YYYY, hh:mm A')}</p>
                  <p>{`${countryName}: ${leagueName}`}</p>
                </div>
                <p>{GameStatusText[getGameStatus({ graphStatus: gameStatus, startsAt: +startsAt, isGameInLive: isLive })]}</p>
              </div>
              <div className="flex items-center">
                <Link href={`/events/${sportSlug}/${gameId}`} className="flex items-center mr-4">
                  {
                    participants.map(({ image, name }) => (
                      <div key={name} className="flex items-center ml-2 first-of-type:ml-0">
                        <div className="flex items-center justify-center w-8 h-8 mr-2 border border-zinc-300 rounded-full">
                          {
                            Boolean(image) && (
                              <img className="w-4 h-4" src={image!} alt="" />
                            )
                          }
                        </div>
                        <span className="text-md">{name}</span>
                      </div>
                    ))
                  }
                </Link>
              </div>
              <div className="grid md:grid-cols-3 md:gap-16">
                <div>
                  <p>Market</p>
                  <p>{ marketName }</p>
                </div>
                <div>
                  <p>Outcome</p>
                  <p>{ selectionName }</p>
                </div>
                <div className="min-w-40 pr-4">
                  <p>Odds</p>
                  <p>{odds}</p>
                </div>
              </div>
            </div>
          )
        })
      }
      <div className="flex items-center justify-between mt-2 flex-wrap">
        <div className='flex items-center'>
          <p>Bet Amount:</p>
          &nbsp;
          <p>{`${amount} ${betToken.symbol}`}</p>
        </div>
        <div className="flex items-center flex-wrap">
          <div className='flex items-center mr-4'>
            <p>{isWin ? 'Winning:' : 'Possible Win:'}</p>
            &nbsp;
            <p>{winAmount}</p>
          </div>
          {
            isRedeemed ? (
              <p>Redeemed</p>
            ) : (
              Boolean(payout || (isCanceled && !freebetId) || isRedeemed) && (
                <button
                  className={cx('md:w-[200px] py-3.5 text-white font-semibold text-center rounded-xl', {
                    'bg-blue-500 hover:bg-blue-600 transition shadow-md': !isDisabled,
                    'bg-zinc-300 cursor-not-allowed': isDisabled,
                  })}
                  disabled={isDisabled}
                  onClick={handleRedeem}
                >
                  {buttonTitle}
                </button>
              )
            )
          }
        </div>
      </div>
    </div>
  )
}

RedeemAll button component

It's possible to redeem all single and combo bets in a single transaction. So we can create component RedeemAll and add it to the UI. SDK hook useRedeemBet accepts array of bets, so it will be easy to implement. The only limitation, that we can't redeem freebets*, so we must filter them.

* Freebets have their own logic which you can learn about in Freebets section once you've mastered the basic app logic and are ready to delve deeper.

Create RedeemAll.tsx in the src/components folder:

src/components/RedeemAll.tsx
'use client'
import React from 'react'
import { type Bet, useRedeemBet } from '@azuro-org/sdk'
import cx from 'clsx'
 
 
type Props = {
  bets: Bet[]
}
 
export function RedeemAll(props: Props) {
  const { bets } = props
 
  const { submit, isPending, isProcessing } = useRedeemBet()
 
  const unredeemedBets = bets?.filter((bet) => (
    !bet.freebetContractAddress
    && bet.isRedeemable
  ))
 
  const isDisabled = !unredeemedBets.length || isPending || isProcessing
 
  const handleRedeem = async () => {
    try {
      await submit({ bets: unredeemedBets })
    } catch {}
  }
 
  let buttonTitle = 'Redeem all'
 
  if (isPending) {
    buttonTitle = 'Waiting for approval'
  }
  else if (isProcessing) {
    buttonTitle = 'Processing...'
  }
 
  return (
    <button
      className={cx('md:w-[200px] py-3.5 text-white font-semibold text-center rounded-xl mb-4', {
        'bg-blue-500 hover:bg-blue-600 transition shadow-md': !isDisabled,
        'bg-zinc-300 cursor-not-allowed': isDisabled,
      })}
      disabled={isDisabled}
      onClick={handleRedeem}
    >
      {buttonTitle}
    </button>
  )
}
⚠️

Don't forget to add export to src/components/index.ts.

Now we're ready to implement bets history page.

Bets history page

Create folder bets in the folder src/app and file page.tsx inside.

Here we'll use usePrematchBets and useLiveBets hook from the SDK to fetch bets and our created components BetCard and RedeemAll to render them.

src/app/bets/page.tsx
'use client'
 
import { useMemo } from 'react'
import { usePrematchBets, useLiveBets, OrderDirection } from '@azuro-org/sdk'
import { useAccount } from 'wagmi'
import { BetCard, RedeemAll } from '@/components'
 
 
export default function Bets() {
  const { address } = useAccount()
 
  const props = {
    filter: {
      bettor: address!,
    },
    orderDir: OrderDirection.Desc,
  }
 
  const { loading: isPrematchLoading, bets: prematchBets } = usePrematchBets(props)
  const { loading: isLiveLoading, bets: liveBets } = useLiveBets(props)
 
  const bets = useMemo(() => {
    return [ ...liveBets, ...prematchBets ].sort((betA, betB) => betB.createdAt - betA.createdAt)
  }, [ prematchBets, liveBets ])
 
  if (isLiveLoading || isPrematchLoading) {
    return <div>Loading...</div>
  }
 
  if (!bets.length) {
    return <div>You don't have bets yet</div>
  }
 
  return (
    <div>
      <RedeemAll bets={bets} />
      {
        bets.map(bet => (
          <BetCard key={`${bet.createdAt}-${bet.tokenId}`} bet={bet} />
        ))
      }
    </div>
  )
}

That's all! Let's check how it looks, open localhost:3000 (opens in a new tab) from your browser. If you already made a bet, you will see something like this:

Learn more

In our tutorial we're building simple betting app. If you're ready to go deeper, learn things from SDK that we used in this section: