Developer Hub
🔮 For applications
Guides & Tutorials
Tutorial: build your dApp
Step 4: Create a Page With Sport Events

Create a Page With Sport Events

Azuro stores data from the blockchain in the Subgraph (GraphQL). You don't need any queries, all it's packaged under the hood of our SDK.

UI components

In this section we will create components for the Page.

OutcomeButton

This component will serve to display all outcomes with live-updated odds and status, alongside "add to betslip" functionality. There is a hooks in SDK to make it easy.

Create src/components/OutcomeButton.tsx.

src/components/OutcomeButton.tsx
'use client'
import { type MarketOutcome, useSelection, useBaseBetslip } from '@azuro-org/sdk'
import cx from 'clsx';
 
type OutcomeProps = {
  className?: string
  outcome: MarketOutcome
}
 
export function OutcomeButton(props: OutcomeProps) {
  const { className, outcome } = props
 
  const { items, addItem, removeItem } = useBaseBetslip()
  const { odds, isLocked, isOddsFetching } = useSelection({
    selection: outcome,
    initialOdds: outcome.odds,
    initialStatus: outcome.status,
  })
 
  const isActive = Boolean(items?.find((item) => {
    const propsKey = `${outcome.coreAddress}-${outcome.lpAddress}-${outcome.gameId}-${outcome.conditionId}-${outcome.outcomeId}`
    const itemKey = `${item.coreAddress}-${item.lpAddress}-${item.game.gameId}-${item.conditionId}-${item.outcomeId}`
 
    return propsKey === itemKey
  }))
 
  const buttonClassName = cx(`flex justify-between p-5 transition rounded-2xl cursor-pointer w-full disabled:cursor-not-allowed disabled:opacity-50 ${className}`, {
    'bg-slate-200 hover:bg-slate-300': isActive,
    'bg-zinc-50 hover:bg-zinc-100': !isActive,
  })
 
  const handleClick = () => {
    const item = {
      gameId: String(outcome.gameId),
      conditionId: String(outcome.conditionId),
      outcomeId: String(outcome.outcomeId),
      coreAddress: outcome.coreAddress,
      lpAddress: outcome.lpAddress,
      isExpressForbidden: outcome.isExpressForbidden,
    }
    if (isActive) {
      removeItem(String(outcome.gameId))
    } else {
      addItem(item)
    }
  }
 
  return (
    <button
      className={buttonClassName}
      onClick={handleClick}
      disabled={isLocked}
    >
      <span className="text-zinc-500">{outcome.selectionName}</span>
      <span className="font-medium">{isOddsFetching ? '--' : odds.toFixed(2)}</span>
    </button>
  )
}
⚠️

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

League

The League component to render leagues with games and markets.

src/components/League.tsx
'use client'
import { GamesQuery, SportsQuery, useGameStatus, useGameMarkets, useLive } from '@azuro-org/sdk'
import Link from 'next/link'
import cx from 'clsx'
import { useParams } from 'next/navigation'
import dayjs from 'dayjs'
 
import { OutcomeButton } from './index'
 
 
type GameProps = {
  className?: string
  game: GamesQuery['games'][0]
}
 
function Game(props: GameProps) {
  const { className, game } = props
  const { gameId, title, startsAt, status: graphStatus } = game
 
  const { isLive } = useLive()
  const { status } = useGameStatus({
    graphStatus,
    startsAt: +startsAt,
    isGameExistInLive: isLive,
  })
  const { markets } = useGameMarkets({
    gameStatus: status,
    gameId,
  })
 
  return (
    <div className={cx(className, "p-2 bg-zinc-200 rounded-lg flex items-center justify-between")}>
      <div className='max-w-[220px] w-full'>
        <Link 
          className="text-sm mb-2 hover:underline block whitespace-nowrap overflow-hidden text-ellipsis w-full" 
          href={`/event/${gameId}`}
        >
          {title}
        </Link>
        <div>{dayjs(+startsAt * 1000).format('DD MMM HH:mm')}</div>
      </div>
      {
        Boolean(markets?.[0]?.outcomeRows[0]) && (
          <div className="min-w-[500px]">
            <div className="text-center">{markets![0].name}</div>
            <div className="flex items-center">
              {
                markets![0].outcomeRows[0].map((outcome) => (
                  <OutcomeButton
                    className="ml-2 first-of-type:ml-0"
                    key={outcome.selectionName}
                    outcome={outcome}
                  />
                ))
              }
            </div>
          </div>
        )
      }
      <Link 
        className="text-md p-2 rounded-lg bg-zinc-100 hover:underline" 
        href={`/event/${gameId}`}
      >
        All Markets =&gt;
      </Link>
    </div>
  )
}
 
type LeagueProps = {
  className?: string
  sportSlug: string
  countryName: string
  countrySlug: string
  league: SportsQuery['sports'][0]['countries'][0]['leagues'][0]
}
 
export function League(props: LeagueProps) {
  const { className, sportSlug, countryName, countrySlug, league } = props
  const { games } = league
 
  const params = useParams()
 
  const isLeaguePage = params.league
  
  return (
    <div
      className={cx(className, {
        "p-4 bg-zinc-50 rounded-md": !isLeaguePage
      })}>
        <div className={cx("flex items-center mb-2", {
          "text-sm": !isLeaguePage,
          "text-lg font-bold": isLeaguePage
        })}>
          {
            isLeaguePage && (
              <>
                <Link 
                  className="hover:underline w-fit"
                  href={`/events/${sportSlug}/${countrySlug}`}
                >
                  <div className="ml-2">{countryName}</div>
                </Link>
                <div className="mx-2">&middot;</div>
              </>
            )
          }
          <Link 
            className="hover:underline w-fit"
            href={`/events/${sportSlug}/${countrySlug}/${league.slug}`}
          >
            {league.name}
          </Link>
        </div>
        {
          games.map(game => (
            <Game 
              key={game.gameId}
              className="mt-2 first-of-type:mt-0"
              game={game} 
            />
          ))
        }
    </div>
  )
}
⚠️

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

Country

The Country component to render countries.

src/components/Country.tsx
'use client'
import { SportsQuery } from '@azuro-org/sdk'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import cx from 'clsx'
 
import { League } from './Leaglue'
 
type CountryProps = {
  className?: string
  sportSlug: string
  country: SportsQuery['sports'][0]['countries'][0]
}
 
export function Country(props: CountryProps) {
  const { className, sportSlug, country } = props
  const { leagues } = country
 
  const params = useParams()
 
  const isCountryPage = params.country
  const isLeaguePage = params.league
  
  return (
    <div
      className={cx(className, {
        "p-4 bg-zinc-100 rounded-3xl": !isCountryPage && !isLeaguePage
      })}>
        {
          !isLeaguePage && (
            <Link 
              className={cx("mb-2 hover:underline", {
                "text-md font-medium": !isCountryPage,
                "text-lg font-bold": isCountryPage
              })} 
              href={`/events/${sportSlug}/${country.slug}`}
            >
              {country.name}
            </Link>
          )
        }
        {
          leagues.map(league => (
            <League 
              key={league.slug}
              className="mt-2 first-of-type:mt-0"
              league={league} 
              sportSlug={sportSlug} 
              countryName={country.name}
              countrySlug={country.slug}
            />
          ))
        }
    </div>
  )
}
⚠️

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

Sport

The Sport component to render list of sports.

src/components/Sport.tsx
'use client'
import { SportsQuery } from '@azuro-org/sdk'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import cx from 'clsx'
 
import { Country } from './Country'
 
 
type SportProps = {
  sport: SportsQuery['sports'][0]
}
 
export function Sport(props: SportProps) {
  const { sport } = props
  const { countries } = sport
  const params = useParams()
 
  const isSportPage = params.sport !== 'top'
 
  return (
    <div
      className={cx({
        "p-4 bg-zinc-50 rounded-3xl mt-2 first-of-type:mt-0": !isSportPage
      })}
    >
      {
        !isSportPage && (
          <Link 
            className="text-lg mb-2 hover:underline font-bold" 
            href={`/events/${sport.slug}`}
          >
            {sport.name}
          </Link>
        )
      }
      {
        countries.map(country => (
          <Country 
            key={country.slug} 
            className="mt-2 first-of-type:mt-0" 
            country={country} 
            sportSlug={sport.slug} 
          />
        ))
      }
    </div>
  )
}
 
⚠️

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

Sports navigation bar

To show navigation between sports, let's add small helping component "ActiveLink". It's just a wrapper of Next.JS Link but handles active state. Create component src/components/ActiveLink.tsx

src/components/ActiveLink.tsx
'use client'
import React, { useState, useEffect } from 'react'
import { usePathname } from 'next/navigation'
import Link, { LinkProps } from 'next/link'
 
 
type ActiveLinkProps = LinkProps & {
  className?: string
  activeClassName: string
  regex?: string
}
 
export const ActiveLink: React.FC<React.PropsWithChildren<ActiveLinkProps>> = (props) => {
  const { children, className, activeClassName, regex, ...rest } = props
 
  const activePathname = usePathname()
  const [ computedClassName, setComputedClassName ] = useState(className)
 
  useEffect(() => {
    // Dynamic route will be matched via props.as
    // Static route will be matched via props.href
    const linkPathname = new URL(
      (rest.as || rest.href) as string,
      location.href
    ).pathname
 
    const isMatch = regex ? new RegExp(regex).test(activePathname) : activePathname === linkPathname
 
    const newClassName = isMatch
      ? `${className} ${activeClassName}`.trim()
      : className
 
    if (newClassName !== computedClassName) {
      setComputedClassName(newClassName)
    }
  }, [
    activePathname,
    rest.as,
    rest.href,
    activeClassName,
    className,
  ])
 
  return (
    <Link className={computedClassName} {...rest}>
      {children}
    </Link>
  )
}

Now let's create navigation bar component - src/components/SportsNavigation.tsx. Azuro SDK has special hook to fetch navigation - useSportsNavigation.

src/components/SportsNavigation.tsx
'use client'
import { useSportsNavigation } from '@azuro-org/sdk'
import { ActiveLink } from '@/components'
 
 
export function SportsNavigation() {
  const { loading, data } = useSportsNavigation({
    withGameCount: true,
  })
 
  if (loading) {
    return <div>Loading...</div>
  }
 
  // it's simple sort by games count, you can implement your own
  const sortedSports = [ ...data?.sports || [] ].sort((a, b) => b.games!.length - a.games!.length)
 
  return (
    <div className="w-full mb-8 overflow-hidden">
      <div className="w-full overflow-x-auto no-scrollbar">
        <div className="flex space-x-1">
          <ActiveLink
            className="py-2 px-4 bg-zinc-100 whitespace-nowrap rounded-full"
            activeClassName="!bg-purple-200"
            href="/events/top"
          >
            Top
          </ActiveLink>
          {
            sortedSports.map(({ slug, name, games }) => (
              <ActiveLink
                key={slug}
                className="flex items-center py-2 px-4 bg-zinc-100 whitespace-nowrap rounded-full"
                activeClassName="!bg-purple-200"
                href={`/events/${slug}`}
              >
                <span>{name}</span>
                {
                  games && (
                    <span className="pl-1.5 text-zinc-400">{games.length}</span>
                  )
                }
              </ActiveLink>
            ))
          }
          <div className="flex-none w-3 h-4" />
        </div>
      </div>
    </div>
  )
}
⚠️

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

Now we're ready to render sports.

Fetch data

First of all you need to create shared layout. Create file layout.tsx in the folder (src/app/events).

For top events we'll use different filters - sort games by turnover and limit them by 10. For sport categories - just filter by sport

src/app/events/layout.tsx
'use client'
import { SportsNavigation, Sport } from '@/components'
import { useParams } from 'next/navigation'
import { useSports, type UseSportsProps, Game_OrderBy, OrderDirection } from '@azuro-org/sdk'
 
const useData = () => {
  const params = useParams()
  const isTopPage = params.sport === 'top'
 
  const props: UseSportsProps = isTopPage ? {
    gameOrderBy: Game_OrderBy.Turnover,
    filter: {
      limit: 10,
    }
  } : {
    gameOrderBy: Game_OrderBy.StartsAt,
    orderDir: OrderDirection.Asc,
    filter: {
      sportSlug: params.sport as string,
      countrySlug: params.country as string,
      leagueSlug: params.league as string,
    }
  }
 
  const { loading, sports } = useSports(props)
 
  return {
    sports,
    loading,
  }
}
 
export default function EventsLayout() {
  const { loading, sports } = useData()
 
  return (
    <>
      <SportsNavigation />
      {
        loading ? (
          <div>Loading...</div>
        ) : (
          <div>
            {
              sports.map((sport) => (
                <Sport key={sport.slug} sport={sport} />
              ))
            }
          </div>
        )
      }
    </>
  )
}

Well done! Now we have page with active events and navigation between sports. Let's check how it works, open localhost:3000 (opens in a new tab) from your browser.

The resulting output should be:

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: