Allow Users to Place Bets
In order to place a bet, the user needs to select the outcome of a sporting event and input the amount they want to bet. To facilitate this process in the UI, we will use a betslip. When the user clicks on an outcome button, a betslip will open. The user can then enter the amount they want to bet, view a summary of their bet, and submit the transaction.
Create Context
Implementing Context
is crucial to enable opening the betslip from any section of our application.
Create filder src/context
and file betslip.tsx
inside.
import { useContext, createContext, useState, useEffect } from 'react'
import { useBaseBetslip } from '@azuro-org/sdk'
export type BetslipContextValue = {
isOpen: boolean
setOpen: (value: boolean) => void
}
export const BetslipContext = createContext<BetslipContextValue | null>(null)
export const useBetslip = () => {
return useContext(BetslipContext) as BetslipContextValue
}
type Props = {
children: React.ReactNode
}
export const BetslipProvider: React.FC<Props> = ({ children }) => {
const { items } = useBaseBetslip()
const [ isOpen, setOpen ] = useState(false)
useEffect(() => {
if (items.length) {
setOpen(true)
}
}, [ items ])
const value = {
isOpen,
setOpen,
}
return (
<BetslipContext.Provider value={value}>
{children}
</BetslipContext.Provider>
);
}
And add this Context to Providers
component:
'use client'
import React from 'react'
import { AzuroSDKProvider, ChainId } from '@azuro-org/sdk'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RainbowKitProvider, getDefaultWallets, getDefaultConfig } from '@rainbow-me/rainbowkit'
import { WagmiProvider } from 'wagmi'
import { polygon, gnosis } from 'viem/chains'
import { BetslipProvider } from '@/context/betslip'
const { wallets } = getDefaultWallets()
const chains = [
polygon,
gnosis,
] as const
const wagmiConfig = getDefaultConfig({
appName: 'Azuro',
appIcon: 'https://path-to-your-icon.com/icon.png',
projectId: '2f82a1608c73932cfc64ff51aa38a87b', // get your own project ID - https://cloud.walletconnect.com/sign-in
wallets,
chains,
})
const queryClient = new QueryClient()
type ProvidersProps = {
children: React.ReactNode
initialChainId?: string
initialLiveState?: boolean
}
export function Providers(props: ProvidersProps) {
const { children, initialChainId, initialLiveState } = props
const chainId = initialChainId
? chains.find(chain => chain.id === +initialChainId) ? +initialChainId as ChainId : polygon.id
: polygon.id
return (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider>
<AzuroSDKProvider initialChainId={chainId} initialLiveState={initialLiveState}>
<BetslipProvider>
{children}
</BetslipProvider>
</AzuroSDKProvider>
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
)
}
Create the Betslip
Create file Betslip.tsx
in src/components
.
AmountInput
Here we will show user's balance of betting token and handle amount input for bet.
function AmountInput() {
const { betAmount, changeBetAmount} = useDetailedBetslip()
const { betToken } = useChain()
const { loading: isBalanceFetching, balance } = useBetTokenBalance()
return (
<div className="mt-4 pt-4 border-t border-zinc-300 space-y-2">
<div className="flex items-center justify-between">
<span className="text-md text-zinc-400">Wallet balance</span>
<span className="text-md font-semibold">
{
isBalanceFetching ? (
<>Loading...</>
) : (
balance !== undefined ? (
<>{(+balance).toFixed(2)} {betToken.symbol}</>
) : (
<>-</>
)
)
}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-md text-zinc-400">Bet amount</span>
<input
className="w-[140px] py-2 px-4 border border-zinc-400 text-md text-right font-semibold rounded-md"
type="number"
placeholder="Bet amount"
value={betAmount}
onChange={(event) => changeBetAmount(event.target.value)}
/>
</div>
</div>
)
}
SubmitButton
To submit a bet we will create a button, which will prepare betting transaction and handle all the business logic.
const errorPerDisableReason = {
[BetslipDisableReason.ComboWithForbiddenItem]: 'One or more conditions can\'t be used in combo',
[BetslipDisableReason.BetAmountGreaterThanMaxBet]: 'Bet amount exceeds max bet',
[BetslipDisableReason.BetAmountLowerThanMinBet]: 'Bet amount lower than min bet',
[BetslipDisableReason.ComboWithLive]: 'Live outcome can\'t be used in combo',
[BetslipDisableReason.ConditionStatus]: 'One or more outcomes have been removed or suspended. Review your betslip and remove them.',
[BetslipDisableReason.PrematchConditionInStartedGame]: 'Game has started',
} as const
const SubmitButton: React.FC = () => {
const { appChain, isRightNetwork } = useChain()
const { items, clear } = useBaseBetslip()
const { betAmount, odds, totalOdds, isStatusesFetching, isOddsFetching, isBetAllowed } = useDetailedBetslip()
const { loading: isBalanceFetching, balance } = useBetTokenBalance()
const {
submit,
approveTx,
betTx,
isRelayerFeeLoading,
isAllowanceLoading,
isApproveRequired,
} = usePrepareBet({
betAmount,
slippage: 10,
affiliate: '0x0000000000000000000000000000000000000000', // your affiliate address
selections: items,
odds,
totalOdds,
onSuccess: () => {
clear()
},
})
const isPending = approveTx.isPending || betTx.isPending
const isProcessing = approveTx.isProcessing || betTx.isProcessing
if (!isRightNetwork) {
return (
<div className="mt-6 py-3.5 text-center bg-red-200 rounded-2xl">
Switch network to <b>{appChain.name}</b> in your wallet
</div>
)
}
const isEnoughBalance = isBalanceFetching || !Boolean(+betAmount) ? true : Boolean(+balance! > +betAmount)
const isLoading = (
isOddsFetching
|| isBalanceFetching
|| isStatusesFetching
|| isAllowanceLoading
|| isPending
|| isProcessing
|| isRelayerFeeLoading
)
const isDisabled = (
isLoading
|| !isBetAllowed
|| !isEnoughBalance
|| !+betAmount
)
let title
if (isPending) {
title = 'Waiting for approval'
}
else if (isProcessing) {
title = 'Processing...'
}
else if (isLoading) {
title = 'Loading...'
}
else if (isApproveRequired) {
title = 'Approve'
}
else {
title = 'Place Bet'
}
return (
<div className="mt-6">
{
!isEnoughBalance && (
<div className="mb-1 text-red-500 text-center font-semibold">
Not enough balance.
</div>
)
}
<button
className={cx('w-full 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={submit}
>
{title}
</button>
</div>
)
}
Betslip itself
Now we have components to create a betslip to place a bet. Here we'll need your affiliate address, which we described here.
Before first bet each user must approve token spending by Azuro Liquidity Pool contract. It's already handled by usePrepareBet
hook from Azuro SDK, so we need just use it.
function Content() {
const account = useAccount()
const { items, clear, removeItem } = useBaseBetslip()
const { betAmount, odds, totalOdds, statuses, disableReason, isStatusesFetching, isOddsFetching, isLiveBet } = useDetailedBetslip()
const { formattedRelayerFeeAmount, loading: isRelayerFeeLoading } = useLiveBetFee({
enabled: isLiveBet,
})
return (
<div className="bg-zinc-100 p-4 mb-4 rounded-md w-full max-h-[90vh] overflow-auto border border-solid">
<div className="flex items-center justify-between mb-2">
<div className="">Betslip {items.length > 1 ? 'Combo' : 'Single'} {items.length ? `(${items.length})`: ''}</div>
{
Boolean(items.length) && (
<button onClick={clear}>Remove All</button>
)
}
</div>
{
Boolean(items.length) ? (
<>
<div className="max-h-[300px] overflow-auto">
{
items.map(item => {
const { game: { gameId, startsAt, sportName, leagueName, participants }, conditionId, outcomeId } = item
const marketName = getMarketName({ outcomeId })
const selection = getSelectionName({ outcomeId, withPoint: true })
const isLock = !isStatusesFetching && statuses[conditionId] !== ConditionStatus.Created
return (
<div key={gameId} className="bg-zinc-50 p-2 rounded-md mt-2 first-of-type:mt-0">
<div className="flex items-center justify-between mb-2">
<div>{sportName} / {leagueName}</div>
<button onClick={() => removeItem(gameId)}>Remove</button>
</div>
<div className="flex items-center justify-between mb-2">
{
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 p-1 mr-2 border border-zinc-300 rounded-full">
{
Boolean(image) && (
<img className="w-full h-full" src={image!} alt="" />
)
}
</div>
<span className="text-md">{name}</span>
</div>
))
}
</div>
<div className="flex items-center justify-between mb-2">
<span className="font-bold">Start Date: </span>
{dayjs(+startsAt * 1000).format('DD MMM HH:mm')}
</div>
<div className="flex items-center justify-between mb-2">
<span className="font-bold">Market: </span>
{marketName}
</div>
<div className="flex items-center justify-between mb-2">
<span className="font-bold">Selection: </span>
{selection}
</div>
<div className="flex items-center justify-between ">
<span className="font-bold">Odds: </span>
{
isOddsFetching ? (
<div className="span">Loading...</div>
) : (
odds[`${conditionId}-${outcomeId}`]
)
}
</div>
{
isLock && (
<div className="text-orange-200 text-center">Outcome removed or suspended</div>
)
}
</div>
)
})
}
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-md text-zinc-400">Total Odds:</span>
<span className="text-md font-semibold">
{
isOddsFetching ? (
<>Loading...</>
) : (
<>{ totalOdds }</>
)
}
</span>
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-md text-zinc-400">Possible win:</span>
<span className="text-md font-semibold">
{
isOddsFetching ? (
<>Loading...</>
) : (
<>{totalOdds * +betAmount}</>
)
}
</span>
</div>
{
Boolean(isRelayerFeeLoading || formattedRelayerFeeAmount) && (
<div className="flex items-center justify-between mt-4">
<span className="text-md text-zinc-400">Relayer fee:</span>
<span className="text-md font-semibold">
{
isRelayerFeeLoading ? (
<>Loading...</>
) : (
<>{formattedRelayerFeeAmount}</>
)
}
</span>
</div>
)
}
<AmountInput />
{
Boolean(disableReason) && (
<div className="mb-1 text-red-500 text-center font-semibold">
{errorPerDisableReason[disableReason!]}
</div>
)
}
{
account?.address ? (
<SubmitButton />
) : (
<div className="mt-6 py-3.5 text-center bg-red-200 rounded-2xl">
Connect your wallet
</div>
)
}
</>
) : (
<div>Empty</div>
)
}
</div>
)
}
export function Betslip() {
const { isOpen, setOpen } = useBetslip()
const { items } = useBaseBetslip()
return (
<div className="fixed bottom-4 right-4 w-full max-w-full md:max-w-sm">
{
isOpen && (
<Content />
)
}
<button
className="flex items-center py-2 px-4 bg-zinc-100 whitespace-nowrap rounded-full ml-auto"
onClick={() => setOpen(!isOpen)}
>
Betslip {items.length || ''}
</button>
</div>
)
}
Full Betslip code
'use client'
import React from 'react'
import cx from 'clsx'
import {
ConditionStatus,
useBaseBetslip,
useBetTokenBalance,
useChain,
useDetailedBetslip,
BetslipDisableReason,
useLiveBetFee,
usePrepareBet,
} from '@azuro-org/sdk'
import { getMarketName, getSelectionName } from '@azuro-org/dictionaries'
import { useAccount } from 'wagmi'
import dayjs from 'dayjs'
import { useBetslip } from '@/context/betslip'
function AmountInput() {
const { betAmount, changeBetAmount, maxBet, minBet } = useDetailedBetslip()
const { betToken } = useChain()
const { loading: isBalanceFetching, balance } = useBetTokenBalance()
return (
<div className="mt-4 pt-4 border-t border-zinc-300 space-y-2">
<div className="flex items-center justify-between">
<span className="text-md text-zinc-400">Wallet balance:</span>
<span className="text-md font-semibold">
{
isBalanceFetching ? (
<>Loading...</>
) : (
balance !== undefined ? (
<>{(+balance).toFixed(2)} {betToken.symbol}</>
) : (
<>-</>
)
)
}
</span>
</div>
{
Boolean(maxBet) && <div className="flex items-center justify-between">
<span className="text-md text-zinc-400">Max bet amount:</span>
<span className="text-md font-semibold">{maxBet} {betToken.symbol}</span>
</div>
}
{
Boolean(minBet) && <div className="flex items-center justify-between">
<span className="text-md text-zinc-400">Min bet amount:</span>
<span className="text-md font-semibold">{minBet} {betToken.symbol}</span>
</div>
}
<div className="flex items-center justify-between">
<span className="text-md text-zinc-400">Bet amount</span>
<input
className="w-[140px] py-2 px-4 border border-zinc-400 text-md text-right font-semibold rounded-md"
type="number"
placeholder="Bet amount"
value={betAmount}
onChange={(event) => changeBetAmount(event.target.value)}
/>
</div>
</div>
)
}
const errorPerDisableReason = {
[BetslipDisableReason.ComboWithForbiddenItem]: 'One or more conditions can\'t be used in combo',
[BetslipDisableReason.BetAmountGreaterThanMaxBet]: 'Bet amount exceeds max bet',
[BetslipDisableReason.BetAmountLowerThanMinBet]: 'Bet amount lower than min bet',
[BetslipDisableReason.ComboWithLive]: 'Live outcome can\'t be used in combo',
[BetslipDisableReason.ConditionStatus]: 'One or more outcomes have been removed or suspended. Review your betslip and remove them.',
[BetslipDisableReason.PrematchConditionInStartedGame]: 'Game has started',
} as const
const SubmitButton: React.FC = () => {
const { appChain, isRightNetwork } = useChain()
const { items, clear } = useBaseBetslip()
const { betAmount, odds, totalOdds, isStatusesFetching, isOddsFetching, isBetAllowed } = useDetailedBetslip()
const { loading: isBalanceFetching, balance } = useBetTokenBalance()
const {
submit,
approveTx,
betTx,
isRelayerFeeLoading,
isAllowanceLoading,
isApproveRequired,
} = usePrepareBet({
betAmount,
slippage: 10,
affiliate: '0x0000000000000000000000000000000000000000', // your affiliate address
selections: items,
odds,
totalOdds,
onSuccess: () => {
clear()
},
})
const isPending = approveTx.isPending || betTx.isPending
const isProcessing = approveTx.isProcessing || betTx.isProcessing
if (!isRightNetwork) {
return (
<div className="mt-6 py-3.5 text-center bg-red-200 rounded-2xl">
Switch network to <b>{appChain.name}</b> in your wallet
</div>
)
}
const isEnoughBalance = isBalanceFetching || !Boolean(+betAmount) ? true : Boolean(+balance! > +betAmount)
const isLoading = (
isOddsFetching
|| isBalanceFetching
|| isStatusesFetching
|| isAllowanceLoading
|| isPending
|| isProcessing
|| isRelayerFeeLoading
)
const isDisabled = (
isLoading
|| !isBetAllowed
|| !isEnoughBalance
|| !+betAmount
)
let title
if (isPending) {
title = 'Waiting for approval'
}
else if (isProcessing) {
title = 'Processing...'
}
else if (isLoading) {
title = 'Loading...'
}
else if (isApproveRequired) {
title = 'Approve'
}
else {
title = 'Place Bet'
}
return (
<div className="mt-6">
{
!isEnoughBalance && (
<div className="mb-1 text-red-500 text-center font-semibold">
Not enough balance.
</div>
)
}
<button
className={cx('w-full 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={submit}
>
{title}
</button>
</div>
)
}
function Content() {
const account = useAccount()
const { items, clear, removeItem } = useBaseBetslip()
const { betAmount, odds, totalOdds, statuses, disableReason, isStatusesFetching, isOddsFetching, isLiveBet } = useDetailedBetslip()
const { formattedRelayerFeeAmount, loading: isRelayerFeeLoading } = useLiveBetFee({
enabled: isLiveBet,
})
return (
<div className="bg-zinc-100 p-4 mb-4 rounded-md w-full max-h-[90vh] overflow-auto border border-solid">
<div className="flex items-center justify-between mb-2">
<div className="">Betslip {items.length > 1 ? 'Combo' : 'Single'} {items.length ? `(${items.length})`: ''}</div>
{
Boolean(items.length) && (
<button onClick={clear}>Remove All</button>
)
}
</div>
{
Boolean(items.length) ? (
<>
<div className="max-h-[300px] overflow-auto">
{
items.map(item => {
const { game: { gameId, startsAt, sportName, leagueName, participants }, conditionId, outcomeId } = item
const marketName = getMarketName({ outcomeId })
const selection = getSelectionName({ outcomeId, withPoint: true })
const isLock = !isStatusesFetching && statuses[conditionId] !== ConditionStatus.Created
return (
<div key={gameId} className="bg-zinc-50 p-2 rounded-md mt-2 first-of-type:mt-0">
<div className="flex items-center justify-between mb-2">
<div>{sportName} / {leagueName}</div>
<button onClick={() => removeItem(gameId)}>Remove</button>
</div>
<div className="flex items-center justify-between mb-2">
{
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 p-1 mr-2 border border-zinc-300 rounded-full">
{
Boolean(image) && (
<img className="w-full h-full" src={image!} alt="" />
)
}
</div>
<span className="text-md">{name}</span>
</div>
))
}
</div>
<div className="flex items-center justify-between mb-2">
<span className="font-bold">Start Date: </span>
{dayjs(+startsAt * 1000).format('DD MMM HH:mm')}
</div>
<div className="flex items-center justify-between mb-2">
<span className="font-bold">Market: </span>
{marketName}
</div>
<div className="flex items-center justify-between mb-2">
<span className="font-bold">Selection: </span>
{selection}
</div>
<div className="flex items-center justify-between ">
<span className="font-bold">Odds: </span>
{
isOddsFetching ? (
<div className="span">Loading...</div>
) : (
odds[`${conditionId}-${outcomeId}`]
)
}
</div>
{
isLock && (
<div className="text-orange-200 text-center">Outcome removed or suspended</div>
)
}
</div>
)
})
}
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-md text-zinc-400">Total Odds:</span>
<span className="text-md font-semibold">
{
isOddsFetching ? (
<>Loading...</>
) : (
<>{ totalOdds }</>
)
}
</span>
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-md text-zinc-400">Possible win:</span>
<span className="text-md font-semibold">
{
isOddsFetching ? (
<>Loading...</>
) : (
<>{totalOdds * +betAmount}</>
)
}
</span>
</div>
{
Boolean(isRelayerFeeLoading || formattedRelayerFeeAmount) && (
<div className="flex items-center justify-between mt-4">
<span className="text-md text-zinc-400">Relayer fee:</span>
<span className="text-md font-semibold">
{
isRelayerFeeLoading ? (
<>Loading...</>
) : (
<>{formattedRelayerFeeAmount}</>
)
}
</span>
</div>
)
}
<AmountInput />
{
Boolean(disableReason) && (
<div className="mb-1 text-red-500 text-center font-semibold">
{errorPerDisableReason[disableReason!]}
</div>
)
}
{
account?.address ? (
<SubmitButton />
) : (
<div className="mt-6 py-3.5 text-center bg-red-200 rounded-2xl">
Connect your wallet
</div>
)
}
</>
) : (
<div>Empty</div>
)
}
</div>
)
}
export function Betslip() {
const { isOpen, setOpen } = useBetslip()
const { items } = useBaseBetslip()
return (
<div className="fixed bottom-4 right-4 w-full max-w-full md:max-w-sm">
{
isOpen && (
<Content />
)
}
<button
className="flex items-center py-2 px-4 bg-zinc-100 whitespace-nowrap rounded-full ml-auto"
onClick={() => setOpen(!isOpen)}
>
Betslip {items.length || ''}
</button>
</div>
)
}
Don't forget to add export to src/components/index.ts
.
Add the Betslip to UI
Update src/app/layout.tsx
:
import '@rainbow-me/rainbowkit/styles.css'
import './globals.css'
import { Providers, Header, Betslip } from '@/components'
import { cookies } from 'next/headers'
export default function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
const cookieStore = cookies()
const initialChainId = cookieStore.get('appChainId')?.value
const initialLiveState = JSON.parse(cookieStore.get('live')?.value || 'false')
return (
<html lang="en">
<body>
<Providers initialChainId={initialChainId} initialLiveState={initialLiveState}>
<Header />
<main className="pt-5 pb-10">
{children}
<Betslip />
</main>
</Providers>
</body>
</html>
)
}
Now click on any outcome button should open the Betslip
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: useChain, useBetTokenBalance