├── .eslintrc.json ├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── inspectionProfiles │ └── Project_Default.xml ├── misc.xml ├── modules.xml ├── prettier.xml ├── ultrabet-ui.iml └── vcs.xml ├── README.md ├── codegen.ts ├── graphql.config.yml ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── public ├── next.svg ├── ui.js └── vercel.svg ├── remote-schema.graphql ├── src ├── app │ ├── (events) │ │ ├── [group] │ │ │ ├── [market] │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── apollo-wrapper.tsx │ │ ├── layout.tsx │ │ ├── page.module.css │ │ └── page.tsx │ ├── api │ │ ├── auth │ │ │ └── [auth0] │ │ │ │ └── route.ts │ │ ├── slip-options │ │ │ ├── [id] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ └── slip │ │ │ └── route.ts │ ├── bets │ │ ├── loading.tsx │ │ ├── page.module.css │ │ └── page.tsx │ ├── design-tokens.css │ ├── favicon.ico │ ├── global-layout.css │ ├── layout.tsx │ └── utilities.css ├── gql │ ├── documents.generated.ts │ ├── documents.graphql │ └── types.generated.ts ├── lib │ ├── apollo-link.ts │ ├── client.ts │ ├── slip-context.tsx │ ├── useSlip.ts │ └── util.ts └── ui │ ├── bet-slip │ ├── bet-slip.module.css │ ├── bet-slip.tsx │ ├── place-bet-form.tsx │ └── remove-slip-option-form.tsx │ ├── card │ ├── card-actions.module.css │ ├── card-actions.tsx │ ├── card-content.module.css │ ├── card-content.tsx │ ├── card-header.module.css │ ├── card-header.tsx │ ├── card-media.module.css │ ├── card-media.tsx │ ├── card.module.css │ └── card.tsx │ ├── date-util.ts │ ├── event-list │ ├── add-slip-option-form.tsx │ ├── elapsed-time.tsx │ ├── event-list.module.css │ ├── event-list.tsx │ ├── live-event-list.tsx │ └── market.tsx │ ├── event-util.ts │ ├── globals.module.css │ ├── page-nav.module.css │ ├── page-nav.tsx │ ├── side-menu │ └── side-menu.tsx │ ├── sport-list │ ├── sport-list.module.css │ └── sport-list.tsx │ └── top-bar │ ├── top-bar.module.css │ └── top-bar.tsx └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | # GitHub Copilot persisted chat sessions 10 | /copilot/chatSessions 11 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 18 | 19 | 21 | 22 | 31 | 32 | 35 | 36 | 43 | 44 | 51 | 52 | 59 | 60 | 65 | 66 | 73 | 74 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /.idea/ultrabet-ui.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A sports betting webapp 2 | 3 | Demo betting frontend: https://www.parabolicbet.com/ 4 | 5 | * Shows in-play (live) events and upcoming events in different tabs 6 | * Live event odds and scores are update as they happen 7 | * The backend is [in my repository called `ultrabet`](https://github.com/anssip/ultrabet) 8 | 9 | [See it live here](https://www.parabolicbet.com/). 10 | 11 | Screenshot 2024-03-13 at 15 56 25 12 | 13 | ## Development 14 | 15 | This is a [Next.js](https://nextjs.org/) project bootstrapped 16 | with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 17 | 18 | First, run the development server: 19 | 20 | ```bash 21 | npm run dev 22 | # or 23 | yarn dev 24 | # or 25 | pnpm dev 26 | ``` 27 | 28 | ## TODO 29 | 30 | See the [complete roadmap here](https://github.com/anssip/ultrabet?tab=readme-ov-file#roadmap) 31 | 32 | - [ ] Add loading state for add to slip action 33 | - [ ] Redesign the bet slip 34 | - [ ] Redesign the bets page 35 | - [ ] Spreads (handicap) market 36 | - [ ] Event view that shows all markets and options for an event 37 | - [ ] Show gravatars (url is in Auth0) 38 | - [ ] Update live scores in bets view 39 | - [ ] Fix global styling 40 | - [x] Show scores in bets view 41 | - [x] Bug: does not accept a long bet only without any singles 42 | - [x] Show some loading indicator when placing a bet 43 | - [x[ Add a loading skeleton for the bets view 44 | - [x] Fix the background color of the bets page 45 | - [x] Bet slip UI 46 | -------------------------------------------------------------------------------- /codegen.ts: -------------------------------------------------------------------------------- 1 | import type { CodegenConfig } from '@graphql-codegen/cli' 2 | 3 | const config: CodegenConfig = { 4 | overwrite: true, 5 | schema: 'https://parabolic-snowy-cloud-5094.fly.dev/graphql', 6 | documents: ['src/**/*.graphql'], 7 | generates: { 8 | 'src/gql/types.generated.ts': { plugins: ['typescript'] }, 9 | 'src/gql/': { 10 | preset: 'near-operation-file', 11 | presetConfig: { 12 | extension: '.generated.ts', 13 | baseTypesPath: 'types.generated.ts', 14 | }, 15 | plugins: ['typescript-operations', 'typed-document-node'], 16 | }, 17 | }, 18 | } 19 | 20 | export default config 21 | -------------------------------------------------------------------------------- /graphql.config.yml: -------------------------------------------------------------------------------- 1 | schema: remote-schema.graphql 2 | extensions: 3 | endpoints: 4 | Parabolic Betting API: 5 | url: https://parabolic-snowy-cloud-5094.fly.dev/graphql 6 | headers: 7 | user-agent: JS GraphQL 8 | introspect: true 9 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ultrabet-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "prebuild": "graphql-codegen --config codegen.ts", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@apollo/client": "3.8.6", 14 | "@apollo/experimental-nextjs-app-support": "^0.5.0", 15 | "@auth0/nextjs-auth0": "^3.2.0", 16 | "@graphql-typed-document-node/core": "^3.2.0", 17 | "@mantine/core": "^6.0.10", 18 | "@mantine/hooks": "^6.0.10", 19 | "@vercel/analytics": "^1.1.1", 20 | "@vercel/kv": "^0.2.3", 21 | "classnames": "^2.5.1", 22 | "eslint": "8.40.0", 23 | "eslint-config-next": "13.4.1", 24 | "graphql": "^16.6.0", 25 | "graphql-ws": "^5.13.1", 26 | "luxon": "^3.4.3", 27 | "next": "14.0.1", 28 | "ramda": "^0.29.0", 29 | "react": "18.3.0-canary-0c6348758-20231030", 30 | "react-dom": "18.3.0-canary-0c6348758-20231030", 31 | "react-transition-group": "^4.4.5" 32 | }, 33 | "devDependencies": { 34 | "@graphql-codegen/cli": "^3.3.1", 35 | "@graphql-codegen/client-preset": "^3.0.1", 36 | "@graphql-codegen/near-operation-file-preset": "^2.5.0", 37 | "@types/luxon": "^3.3.3", 38 | "@types/node": "20.1.0", 39 | "@types/ramda": "^0.29.2", 40 | "@types/react": "18.2.8", 41 | "@types/react-dom": "18.2.4", 42 | "@types/react-transition-group": "^4.4.6", 43 | "prettier": "^2.8.8", 44 | "ts-node": "^10.9.1", 45 | "typescript": "5.1.3" 46 | }, 47 | "prettier": { 48 | "semi": false, 49 | "singleQuote": true, 50 | "trailingComma": "es5", 51 | "printWidth": 100 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/ui.js: -------------------------------------------------------------------------------- 1 | ;(function (window, document) { 2 | // we fetch the elements each time because docusaurus removes the previous 3 | // element references on page navigation 4 | function getElements() { 5 | return { 6 | layout: document.getElementById('layout'), 7 | menu: document.getElementById('menu'), 8 | menuLink: document.getElementById('menuLink'), 9 | } 10 | } 11 | 12 | function toggleClass(element, className) { 13 | var classes = element.className.split(/\s+/) 14 | var length = classes.length 15 | var i = 0 16 | 17 | for (; i < length; i++) { 18 | if (classes[i] === className) { 19 | classes.splice(i, 1) 20 | break 21 | } 22 | } 23 | // The className is not found 24 | if (length === classes.length) { 25 | classes.push(className) 26 | } 27 | 28 | element.className = classes.join(' ') 29 | } 30 | 31 | function toggleAll() { 32 | var active = 'active' 33 | var elements = getElements() 34 | console.log('elements', elements) 35 | 36 | toggleClass(elements.layout, active) 37 | toggleClass(elements.menu, active) 38 | toggleClass(elements.menuLink, active) 39 | } 40 | 41 | function handleEvent(e) { 42 | var elements = getElements() 43 | console.log( 44 | 'target', 45 | e.target.id, 46 | elements.menuLink.id, 47 | elements.menu.className.indexOf('active') !== -1 48 | ) 49 | 50 | if (e.target.id === elements.menuLink.id) { 51 | console.log('toggle all') 52 | toggleAll() 53 | e.preventDefault() 54 | } else if (elements.menu.className.indexOf('active') !== -1) { 55 | toggleAll() 56 | } 57 | } 58 | 59 | document.addEventListener('click', handleEvent) 60 | })(this, this.document) 61 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /remote-schema.graphql: -------------------------------------------------------------------------------- 1 | # This file was generated. Do not edit manually. 2 | 3 | schema { 4 | query: Query 5 | mutation: Mutation 6 | subscription: Subscription 7 | } 8 | 9 | type Bet { 10 | betOptions: [BetOption] 11 | createdAt: String! 12 | id: ID! 13 | potentialWinnings: Float! 14 | stake: Float! 15 | status: BetStatus! 16 | user: User 17 | } 18 | 19 | type BetOption { 20 | bet: Bet! 21 | id: Int! 22 | marketOption: MarketOption! 23 | status: BetStatus 24 | } 25 | 26 | "An Event represents a sports match or competition on which users can place bets." 27 | type Event { 28 | awayTeamName: String! 29 | "Is this event completed?" 30 | completed: Boolean! 31 | "The id of the event in the source system." 32 | externalId: String 33 | homeTeamName: String! 34 | id: ID! 35 | "Is this event currently live?" 36 | isLive: Boolean! 37 | markets(source: String): [Market] 38 | name: String! 39 | result: EventResult 40 | scoreUpdates: [ScoreUpdate] 41 | sport: Sport! 42 | startTime: String! 43 | } 44 | 45 | """ 46 | 47 | A Market represents a specific betting opportunity within an event. 48 | It's usually associated with one aspect of the event that users can bet on. 49 | A single event can have multiple markets. Some common examples of markets 50 | include Moneyline, Point Spread, and Totals (Over/Under). 51 | """ 52 | type Market { 53 | event: Event 54 | id: ID! 55 | "Is this Market available for live betting?" 56 | isLive: Boolean! 57 | """ 58 | 59 | When was this Market last updated? Used to track when the odds were last 60 | updated during live betting. 61 | """ 62 | lastUpdated: String 63 | name: String! 64 | options: [MarketOption] 65 | "What is the source or bookmaker that provides the odds for this Market?" 66 | source: String! 67 | } 68 | 69 | """ 70 | 71 | A MarketOption represents a specific choice or outcome within a market 72 | that users can bet on. Each market typically has two or more options to choose from. 73 | """ 74 | type MarketOption { 75 | description: String 76 | id: ID! 77 | """ 78 | 79 | When was this Market last updated? Used to track when the odds were last 80 | updated during live betting. 81 | """ 82 | lastUpdated: String 83 | market: Market 84 | name: String! 85 | odds: Float! 86 | point: Float 87 | } 88 | 89 | " Mutations" 90 | type Mutation { 91 | " should be available for admins only:" 92 | createEvent(name: String!, sport: String!, startTime: String!): Event 93 | createMarket(eventId: ID!, name: String!): Market 94 | createMarketOption(marketId: ID!, name: String!, odds: Float!): MarketOption 95 | createUser(email: String!, username: String!): User 96 | depositFunds(amount: Float!, userId: ID!): Wallet 97 | "Places a bet on the provided market options." 98 | placeBet(betType: BetType!, marketOptions: [ID!]!, stake: Float!): Bet 99 | "Place multiple single bets, one for each option provided." 100 | placeSingleBets(options: [BetOptionInput!]!): [Bet] 101 | updateResult(eventId: ID!): Event 102 | withdrawFunds(amount: Float!, userId: ID!): Wallet 103 | } 104 | 105 | " Queries" 106 | type Query { 107 | "List events available for betting in the specified sport group. Lists both live an upcoming events." 108 | eventsBySportGroup(group: String!): [Event] 109 | getBet(id: ID!): Bet 110 | getEvent(id: ID!): Event 111 | getMarket(id: ID!): Market 112 | "List all events available for betting. Lists both live an upcoming events." 113 | listAllEvents: [Event] 114 | listBets: [Bet] 115 | "List upcoming events available for betting." 116 | listEvents: [Event] 117 | "List live events available for betting." 118 | listLiveEvents: [Event] 119 | listLiveMarkets(eventId: ID!): [Market] 120 | listMarkets(eventId: ID!): [Market] 121 | "List sports by group." 122 | listSports(group: String): [Sport] 123 | me: User 124 | } 125 | 126 | type ScoreUpdate { 127 | event: Event! 128 | id: ID! 129 | name: String! 130 | score: String! 131 | timestamp: String! 132 | } 133 | 134 | type Sport { 135 | "Is this sport in season at the moment?" 136 | active: Boolean! 137 | activeEventCount: Int 138 | description: String! 139 | "List all events available for betting in this sport." 140 | events: [Event] 141 | group: String! 142 | "Does this sport have outright markets?" 143 | hasOutrights: Boolean! 144 | id: ID! 145 | key: String! 146 | title: String! 147 | } 148 | 149 | type Subscription { 150 | eventScoresUpdated: [Event] 151 | eventStatusUpdated: [Event] 152 | liveMarketOptionsUpdated: [MarketOption] 153 | } 154 | 155 | type Transaction { 156 | amount: Float! 157 | createdAt: String! 158 | id: ID! 159 | transactionType: TransactionType! 160 | wallet: Wallet 161 | } 162 | 163 | type User { 164 | bets: [Bet] 165 | email: String 166 | externalId: String 167 | id: ID! 168 | username: String 169 | wallet: Wallet 170 | } 171 | 172 | type Wallet { 173 | balance: Float! 174 | id: ID! 175 | transactions: [Transaction] 176 | user: User 177 | } 178 | 179 | enum BetStatus { 180 | CANCELED 181 | LOST 182 | PENDING 183 | WON 184 | } 185 | 186 | "https://chat.openai.com/share/92b7bc9f-6fc6-4f57-9a4e-f217270ad271" 187 | enum BetType { 188 | PARLAY 189 | SINGLE 190 | " same as long bet or accumulator" 191 | SYSTEM 192 | } 193 | 194 | enum EventResult { 195 | AWAY_TEAM_WIN 196 | DRAW 197 | HOME_TEAM_WIN 198 | } 199 | 200 | enum TransactionType { 201 | BET_PLACED 202 | BET_REFUNDED 203 | BET_WON 204 | DEPOSIT 205 | WITHDRAWAL 206 | } 207 | 208 | "A slightly refined version of RFC-3339 compliant DateTime Scalar" 209 | scalar DateTime 210 | 211 | input BetOptionInput { 212 | marketOptionId: ID! 213 | stake: Float! 214 | } 215 | -------------------------------------------------------------------------------- /src/app/(events)/[group]/[market]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getClient } from '@/lib/client' 2 | import { ListSportsWithEventsDocument, SportWithEventsFragment } from '@/gql/documents.generated' 3 | import styles from '../../page.module.css' 4 | import React from 'react' 5 | import { MarketNav, PageNav } from '@/ui/page-nav' 6 | import classnames from 'classnames' 7 | import { SportList } from '@/ui/sport-list/sport-list' 8 | 9 | export const revalidate = 0 10 | export const dynamic = 'force-dynamic' 11 | 12 | async function fetchSports(params: { group: string; market: string }) { 13 | const start = new Date().getTime() 14 | console.log('fetchSports', params.group) 15 | try { 16 | const result = await getClient().query({ 17 | query: ListSportsWithEventsDocument, 18 | variables: { group: (params.group ? decodeURIComponent(params.group) : 'all') ?? 'all' }, 19 | }) 20 | console.log('fetchSports completed in ', new Date().getTime() - start) 21 | return result 22 | } catch (e) { 23 | console.error('fetchSports', e) 24 | return { data: { listSports: [] } } 25 | } 26 | } 27 | 28 | export default async function Page({ params }: { params: { group: string; market: string } }) { 29 | const data = await fetchSports(params) 30 | const sports = data.data.listSports as SportWithEventsFragment[] 31 | 32 | return ( 33 |
34 | 35 | 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/app/(events)/[group]/page.tsx: -------------------------------------------------------------------------------- 1 | import MarketPage from './[market]/page' 2 | 3 | export const revalidate = 60 4 | 5 | export default async function Page({ params }: { params: { group: string; market?: string } }) { 6 | return MarketPage({ params: { ...params, market: params.market ?? 'h2h' } }) 7 | } 8 | -------------------------------------------------------------------------------- /src/app/(events)/apollo-wrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ApolloLink, HttpLink } from '@apollo/client' 4 | import { 5 | ApolloNextAppProvider, 6 | NextSSRInMemoryCache, 7 | SSRMultipartLink, 8 | // @ts-ignore 9 | NextSSRApolloClient, 10 | } from '@apollo/experimental-nextjs-app-support/ssr' 11 | import { splitLink } from '@/lib/apollo-link' 12 | import { useUser } from '@auth0/nextjs-auth0/client' 13 | 14 | const makeClient = (isAuthenticated: boolean) => () => { 15 | const httpLink = new HttpLink({ 16 | uri: 17 | (isAuthenticated 18 | ? process.env.NEXT_PUBLIC_BETTING_API_URL 19 | : process.env.NEXT_PUBLIC_API_URL) + '/graphql', 20 | }) 21 | 22 | return new NextSSRApolloClient({ 23 | cache: new NextSSRInMemoryCache(), 24 | link: 25 | typeof window === 'undefined' 26 | ? ApolloLink.from([ 27 | // in a SSR environment, if you use multipart features like 28 | // @defer, you need to decide how to handle these. 29 | // This strips all interfaces with a `@defer` directive from your queries. 30 | new SSRMultipartLink({ 31 | stripDefer: true, 32 | }), 33 | httpLink, 34 | ]) 35 | : splitLink(httpLink), 36 | }) 37 | } 38 | 39 | export function ApolloWrapper({ children }: React.PropsWithChildren) { 40 | const { user } = useUser() 41 | console.log('ApolloWrapper user', user) 42 | return {children} 43 | } 44 | -------------------------------------------------------------------------------- /src/app/(events)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloWrapper } from './apollo-wrapper' 2 | import { SideMenu } from '@/ui/side-menu/side-menu' 3 | import React from 'react' 4 | import { getClient } from '@/lib/client' 5 | import { ListSportsDocument } from '@/gql/documents.generated' 6 | import { Sport } from '@/gql/types.generated' 7 | import '../../app/global-layout.css' 8 | 9 | export default async function EventsLayout({ children }: { children: React.ReactNode }) { 10 | const data = await getClient(false).query({ 11 | query: ListSportsDocument, 12 | }) 13 | const sports = data.data.listSports as Sport[] 14 | 15 | return ( 16 | <> 17 | 18 | {/**/} 19 | 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/app/(events)/page.module.css: -------------------------------------------------------------------------------- 1 | .eventsContainer { 2 | padding: 2em; 3 | background-color: var(--off-white); 4 | } 5 | 6 | .eventsContainer h2 { 7 | margin-top: 0; 8 | } -------------------------------------------------------------------------------- /src/app/(events)/page.tsx: -------------------------------------------------------------------------------- 1 | import MarketPage from './[group]/page' 2 | 3 | export const revalidate = 60 4 | // export const dynamic = 'force-dynamic' 5 | 6 | export default async function Page({ params }: { params: { group: string } }) { 7 | return MarketPage({ params: { ...params, group: params.group } }) 8 | } 9 | -------------------------------------------------------------------------------- /src/app/api/auth/[auth0]/route.ts: -------------------------------------------------------------------------------- 1 | import { handleAuth, handleLogin } from '@auth0/nextjs-auth0' 2 | 3 | export const GET = handleAuth({ 4 | login: handleLogin({ 5 | authorizationParams: { 6 | audience: process.env.AUTH0_AUDIENCE, 7 | 8 | // Add the `offline_access` scope to also get a Refresh Token 9 | scope: 'openid profile email', 10 | }, 11 | }), 12 | }) 13 | -------------------------------------------------------------------------------- /src/app/api/slip-options/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0' 2 | import { NextRequest, NextResponse } from 'next/server' 3 | import { kv } from '@vercel/kv' 4 | import { BetSlipOption } from '@/ui/bet-slip/bet-slip' 5 | import { MarketOption } from '@/gql/types.generated' 6 | 7 | export const DELETE = withApiAuthRequired(async function removeSlipOption(req: NextRequest) { 8 | const res = new NextResponse() 9 | const session = await getSession(req, res) 10 | if (!session) { 11 | return NextResponse.json({ error: 'Unauthorized', status: 400 }, res) 12 | } 13 | const { sub } = session.user 14 | const optionId = req.url.split('/').pop() 15 | if (typeof optionId !== 'string') { 16 | return NextResponse.json({ error: 'A valid optionId is required', status: 400 }, res) 17 | } 18 | await kv.hdel(`betslip:${sub}`, optionId) 19 | return NextResponse.json({ success: true }, res) 20 | }) 21 | -------------------------------------------------------------------------------- /src/app/api/slip-options/route.ts: -------------------------------------------------------------------------------- 1 | import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0' 2 | import { NextRequest, NextResponse } from 'next/server' 3 | import { MarketOption } from '@/gql/types.generated' 4 | import { kv } from '@vercel/kv' 5 | 6 | export const POST = withApiAuthRequired(async function addSlipOption(req: NextRequest) { 7 | const res = new NextResponse() 8 | const session = await getSession(req, res) 9 | if (!session) { 10 | return NextResponse.json({ error: 'Unauthorized', status: 400 }, res) 11 | } 12 | const { sub } = session.user 13 | const option = (await req.json()) as MarketOption 14 | console.log('option', option) 15 | 16 | await kv.hset(`betslip:${sub}`, { [`${option.id}`]: option }) 17 | return NextResponse.json({ success: true }, res) 18 | }) 19 | -------------------------------------------------------------------------------- /src/app/api/slip/route.ts: -------------------------------------------------------------------------------- 1 | import { kv } from '@vercel/kv' 2 | import { NextRequest, NextResponse } from 'next/server' 3 | import { getAccessToken, getSession, withApiAuthRequired } from '@auth0/nextjs-auth0' 4 | import { getClient } from '@/lib/client' 5 | import { PlaceBetDocument, PlaceSingleBetsDocument } from '@/gql/documents.generated' 6 | import { BetType } from '@/gql/types.generated' 7 | import { BetSlipOption, Slip } from '@/ui/bet-slip/bet-slip' 8 | 9 | // https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#app-router-1 10 | export const GET = withApiAuthRequired(async function myApiRoute(req) { 11 | const res = new NextResponse() 12 | const session = await getSession(req, res) 13 | if (!session) { 14 | return NextResponse.json({ error: 'Unauthorized', status: 400 }, res) 15 | } 16 | const slip: Slip = (await kv.hgetall(`betslip:${session.user.sub}`)) ?? {} 17 | return NextResponse.json({ slip, id: session.user.sub }, res) 18 | }) 19 | 20 | async function clearSlip(options: BetSlipOption[], sub: string) { 21 | console.log(`clearing slip for user ${sub}`, options) 22 | await Promise.all(options.map((option) => kv.hdel(`betslip:${sub}`, option.id))) 23 | } 24 | 25 | export const POST = withApiAuthRequired(async function postSlipRoute(req) { 26 | const res = new NextResponse() 27 | const token = await getAccessToken(req, res, { scopes: ['email', 'profile', 'openid'] }) 28 | console.log('got access token', token) 29 | 30 | const bets = await req.json() 31 | console.log('bets', bets) 32 | 33 | const betOptions = Object.values(bets.singles) as BetSlipOption[] 34 | const singles = betOptions.filter((single) => !!single.stake) 35 | const long = bets.long as BetSlipOption 36 | 37 | try { 38 | const client = getClient(true) 39 | const [placeBetData, placeSingleBetsData] = await Promise.all([ 40 | long?.stake ?? 0 > 0 41 | ? client.mutate({ 42 | mutation: PlaceBetDocument, 43 | variables: { 44 | marketOptions: betOptions.map((option) => option.id), 45 | betType: BetType.Parlay, 46 | stake: long.stake as number, 47 | }, 48 | context: { 49 | headers: { 50 | Authorization: `Bearer ${token.accessToken}`, 51 | }, 52 | }, 53 | }) 54 | : Promise.resolve(null), 55 | singles.length > 0 56 | ? client.mutate({ 57 | mutation: PlaceSingleBetsDocument, 58 | variables: { 59 | options: singles.map((option) => ({ 60 | marketOptionId: option.id, 61 | stake: option.stake as number, 62 | })), 63 | }, 64 | context: { 65 | headers: { 66 | Authorization: `Bearer ${token.accessToken}`, 67 | }, 68 | }, 69 | }) 70 | : Promise.resolve(null), 71 | ]) 72 | console.log('got response data', JSON.stringify({ placeBetData, placeSingleBetsData }, null, 2)) 73 | 74 | const session = await getSession(req, res) 75 | await clearSlip([...betOptions, long], session?.user.sub) 76 | 77 | const longBet = placeBetData?.data?.placeBet 78 | const singleBets = placeSingleBetsData?.data?.placeSingleBets 79 | // res.status(200).json({ success: true, data: { singles: singleBets, long: longBet } }) 80 | return NextResponse.json({ success: true, data: { singles: singleBets, long: longBet } }) 81 | } catch (error) { 82 | console.error('error placing bet', error) 83 | // @ts-ignore 84 | return NextResponse.json({ error: error.message, status: 400 }, res) 85 | } 86 | }) 87 | -------------------------------------------------------------------------------- /src/app/bets/loading.tsx: -------------------------------------------------------------------------------- 1 | import styles from './page.module.css' 2 | import React from 'react' 3 | import { Card } from '@/ui/card/card' 4 | import CardHeader from '@/ui/card/card-header' 5 | import { CardContent } from '@/ui/card/card-content' 6 | 7 | export default function Loading() { 8 | return ( 9 |
10 |

My Bets

11 | 12 | 13 | 14 | 15 |

loading...

16 |
17 |
18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/bets/page.module.css: -------------------------------------------------------------------------------- 1 | 2 | .main { 3 | background-color: var(--off-white); 4 | color: var(--dark-grey); 5 | padding-top: 50px; 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | gap: 1em; 10 | min-height: 100vh; 11 | /* no left sidebar here */ 12 | margin: 0 0 0 -190px; 13 | } 14 | 15 | .header { 16 | } 17 | 18 | .betItem { 19 | width: 80%; 20 | display: flex; 21 | flex-direction: column; 22 | color: var(--dark-grey); 23 | border: 1px solid rgb(255, 255, 0, 0.4); 24 | } 25 | 26 | .options { 27 | padding-left: 1em; 28 | } 29 | 30 | .betHeader { 31 | display: flex; 32 | flex-direction: row; 33 | justify-content: space-between; 34 | font-weight: bold; 35 | } 36 | 37 | .betDetails { 38 | list-style: none; 39 | position: relative; 40 | gap: 0.5em; 41 | margin: 0.2em 1.5em; 42 | } 43 | 44 | .betMeta { 45 | font-size: var(--font-size-body); 46 | font-weight: 200; 47 | } 48 | 49 | .status::before { 50 | content: ""; 51 | position: absolute; 52 | width: 7px; 53 | height: 7px; 54 | left: -20px; 55 | top: 5px; 56 | border: 1px solid var(--dark-grey); 57 | border-radius: 50%; 58 | } 59 | 60 | .status-pending { 61 | color: var(--dark-grey); 62 | } 63 | 64 | .status-lost::before { 65 | background-color: red; 66 | border: 2px solid red; 67 | } 68 | 69 | .status-won::before { 70 | background-color: green; 71 | border: 2px solid green; 72 | } 73 | 74 | .status-canceled::before { 75 | border: 2px solid darkgray; 76 | } 77 | 78 | .marketOption { 79 | display: flex; 80 | flex-direction: row; 81 | justify-content: space-between; 82 | font-size: 1.2rem; 83 | } 84 | 85 | .smallText { 86 | font-size: var(--font-size-caption); 87 | font-weight: 400; 88 | } 89 | 90 | .smallestText { 91 | font-size: 0.4rem; 92 | font-weight: 100; 93 | } 94 | 95 | .eventName { 96 | padding: 0.5em; 97 | } 98 | 99 | .score { 100 | padding: 1em; 101 | color: var(--egyptian-blue); 102 | .live { 103 | text-transform: uppercase; 104 | font-family: monospace; 105 | color: var(--salmon); 106 | padding-left: 0.5rem; 107 | } 108 | } 109 | 110 | .numbers { 111 | display: flex; 112 | flex-direction: row; 113 | justify-content: space-between; 114 | align-items: baseline; 115 | gap: 1em; 116 | margin: 0 1.5em; 117 | border-top: 2px solid var(--salmon); 118 | padding-top: 0.5em 119 | } 120 | 121 | .number { 122 | display: flex; 123 | flex-direction: column; 124 | gap: 0.5em; 125 | } -------------------------------------------------------------------------------- /src/app/bets/page.tsx: -------------------------------------------------------------------------------- 1 | import { getClient } from '@/lib/client' 2 | import { ListBetsDocument } from '@/gql/documents.generated' 3 | import styles from './page.module.css' 4 | import React from 'react' 5 | import { Bet, BetOption, BetStatus, Maybe } from '@/gql/types.generated' 6 | import { fetchAccessToken, getLongBetName } from '@/lib/util' 7 | import { redirect } from 'next/navigation' 8 | import { formatTime } from '@/ui/date-util' 9 | import { getOptionPointLabel, getSpreadOptionLabel, renderScore } from '@/ui/event-util' 10 | import { Card } from '@/ui/card/card' 11 | import CardHeader from '@/ui/card/card-header' 12 | import { CardContent } from '@/ui/card/card-content' 13 | 14 | export const revalidate = 60 15 | 16 | const BetListItem: React.FC<{ bet: Bet }> = ({ bet }) => { 17 | const betType = getLongBetName(bet.betOptions?.length ?? 1) 18 | 19 | const betWinLabel = (status: BetStatus): string => { 20 | return status === BetStatus.Won ? 'Won' : 'To Return' 21 | } 22 | const options = bet.betOptions?.filter((option) => !!option) as BetOption[] 23 | const betOptions = options.map((option: BetOption) => { 24 | const event = option?.marketOption.market?.event 25 | return ( 26 |
  • 27 |
    28 |
    29 | 32 | {option?.marketOption?.name}{' '} 33 | {option && 34 | getOptionPointLabel( 35 | option.marketOption ?? null, 36 | option.marketOption?.market?.name ?? 'totals' 37 | )}{' '} 38 | {option?.marketOption.id} 39 |
    40 |
    {option?.marketOption.odds}
    41 |
    42 |
    43 | {option?.marketOption.market?.event?.homeTeamName}{' '} 44 | {getSpreadOptionLabel(option.marketOption.market?.event ?? null, true)} vs{' '} 45 | {option?.marketOption.market?.event?.awayTeamName}{' '} 46 | {getSpreadOptionLabel(option.marketOption.market?.event ?? null, false)} 47 | {event && !event.sport?.key.startsWith('tennis') && ( 48 | 49 | {renderScore(event)}{' '} 50 | 51 | {!event?.result && event.isLive && !event.completed ? ' live' : ''} 52 | 53 | 54 | )} 55 |
    56 |
  • 57 | ) 58 | }) 59 | 60 | return ( 61 | 62 | 63 |
    64 |
    Placed {formatTime(new Date(bet.createdAt))}
    65 |
    66 |
    67 | 68 | 69 |
    70 |
    71 |
    Stake
    72 |
    €{bet.stake}
    73 |
    74 |
    75 |
    {betWinLabel(bet.status)}
    76 |
    €{Number(bet.potentialWinnings).toFixed(2)}
    77 |
    78 |
    79 |
    80 |
    81 | ) 82 | } 83 | 84 | export default async function Page() { 85 | const accessToken = await fetchAccessToken() 86 | if (!accessToken) { 87 | console.info('No access token, redirecting to login') 88 | redirect('/api/auth/login') 89 | } 90 | const data = await getClient(true).query({ 91 | query: ListBetsDocument, 92 | context: { 93 | headers: { 94 | authorization: `Bearer ${accessToken}`, 95 | }, 96 | }, 97 | }) 98 | const bets: Bet[] = data.data.listBets as Bet[] 99 | 100 | if (bets.length === 0) { 101 | return ( 102 |
    103 |

    My Bets

    104 |

    No bets placed yet.

    105 |
    106 | ) 107 | } 108 | 109 | return ( 110 |
    111 |

    My Bets

    112 | {bets?.map((bet) => ( 113 | 114 | ))} 115 |
    116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /src/app/design-tokens.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --egyptian-blue: hsla(227, 75%, 38%, 1); 3 | --peach-yellow: hsla(41, 100%, 71%, 1); 4 | --salmon: hsla(9, 93%, 72%, 1); 5 | --light-blue: hsla(227, 40%, 90%, 1); 6 | --pink-lavender: hsla(314, 48%, 82%, 1); 7 | --off-white: hsla(0, 0%, 96%, 1); 8 | --off-white-transparent: hsla(0, 0%, 96%, 0.8); 9 | --white: hsla(0, 0%, 100%, 1); 10 | --black: hsla(0, 0%, 0%, 1); 11 | --black-transparent: hsla(0, 0%, 0%, 0.7); 12 | --dark-grey: hsla(0, 0%, 20%, 1); 13 | 14 | --spacing-small: 4px; 15 | --spacing-medium: 8px; 16 | --spacing-large: 12px; 17 | --spacing-large-minus: -12px; 18 | --spacing-x-large: 16px; 19 | --duration-paused: paused; 20 | --duration-slow: 3s; 21 | --duration-fast: 500ms; 22 | --easing-easeInSine: cubic-bezier(0.12, 0, 0.39, 0); 23 | --easing-easeOutSine: cubic-bezier(0.61, 1, 0.88, 1)500ms; 24 | --radius-circle: 50%; 25 | --radius-large: 8px; 26 | --radius-small: 2px; 27 | --opacity-opacity-25: 0.25; 28 | --opacity-opacity-50: 0.5; 29 | --opacity-opacity-75: 0.75; 30 | --shadow-level-1: 0 1px 1px 0 rgba(0,0,0,0.14), 0 2px 1px -1px rgba(0,0,0,0.12), 0 1px 3px 0 rgba(0,0,0,0.20);; 31 | --shadow-level-2: 0 3px 4px 0 rgba(0,0,0,0.14), 0 3px 3px -2px rgba(0,0,0,0.12), 0 1px 8px 0 rgba(0,0,0,0.20);; 32 | --shadow-level-3: 0 6px 10px 0 rgba(0,0,0,0.14), 0 1px 18px 0 rgba(0,0,0,0.12), 0 3px 5px -1px rgba(0,0,0,0.20); 33 | --media-query-max-width-mobile: 600px; 34 | --media-query-max-width-tablet: 1024px; 35 | --font-family-body: Arial, Helvetica, sans-serif; 36 | --font-family-headings: Palatino Linotype, serif; 37 | --font-size-caption: 14px; 38 | --font-size-body: 16px; 39 | --font-size-headings: 26px; 40 | --letter-spacing-dense: -1px; 41 | --letter-spacing-double: 2px; 42 | --line-height-heading: 1.25; 43 | --line-height-reset: 1; 44 | --line-height-text: 1.5 45 | } 46 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anssip/ultrabet-ui/e27ce2d0794b7fe1ed69374bd1cea33b2e53b3e8/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/global-layout.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: #777; 3 | } 4 | 5 | /* 6 | Add transition to containers so they can push in and out. 7 | */ 8 | #layout, 9 | #menu, 10 | .menu-link { 11 | -webkit-transition: all 0.2s ease-out; 12 | -moz-transition: all 0.2s ease-out; 13 | -ms-transition: all 0.2s ease-out; 14 | -o-transition: all 0.2s ease-out; 15 | transition: all 0.2s ease-out; 16 | } 17 | 18 | /* 19 | This is the parent `
    ` that contains the menu and the content area. 20 | */ 21 | #layout { 22 | position: relative; 23 | left: 0; 24 | padding-left: 0; 25 | } 26 | #layout.active #menu { 27 | left: 190px; 28 | width: 190px; 29 | } 30 | 31 | #layout.active .menu-link { 32 | left: 190px; 33 | } 34 | /* 35 | The content `
    ` is where all your content goes. 36 | */ 37 | .content { 38 | margin: 0 auto; 39 | padding: 0 2em; 40 | max-width: 800px; 41 | margin-bottom: 50px; 42 | line-height: 1.6em; 43 | } 44 | 45 | .header { 46 | margin: 0; 47 | color: var(--off-white); 48 | text-align: center; 49 | padding: 2.5em 2em 0; 50 | border-bottom: 1px solid #eee; 51 | } 52 | .header h1 { 53 | margin: 0.2em 0; 54 | font-size: 3em; 55 | font-weight: 300; 56 | } 57 | .header h2 { 58 | font-weight: 300; 59 | color: #ccc; 60 | padding: 0; 61 | margin-top: 0; 62 | } 63 | 64 | .content-subhead { 65 | margin: 50px 0 20px 0; 66 | font-weight: 300; 67 | color: #888; 68 | } 69 | 70 | 71 | 72 | /* 73 | The `#menu` `
    ` is the parent `
    ` that contains the `.pure-menu` that 74 | appears on the left side of the page. 75 | */ 76 | 77 | #menu { 78 | margin-left: -190px; /* "#menu" width */ 79 | width: 190px; 80 | position: fixed; 81 | top: 2.1em; 82 | left: 0; 83 | bottom: 0; 84 | z-index: 1000; /* so the menu or its navicon stays above all content */ 85 | background: var(--egyptian-blue); 86 | overflow-y: auto; 87 | } 88 | /* 89 | All anchors inside the menu should be styled like this. 90 | */ 91 | #menu a { 92 | color: var(--off-white); 93 | border: none; 94 | padding: 0.6em 0 0.6em 0.6em; 95 | } 96 | 97 | /* 98 | Remove all background/borders, since we are applying them to #menu. 99 | */ 100 | #menu .pure-menu, 101 | #menu .pure-menu ul { 102 | border: none; 103 | background: transparent; 104 | } 105 | 106 | /* 107 | Add that light border to separate items into groups. 108 | */ 109 | #menu .pure-menu ul, 110 | #menu .pure-menu .menu-item-divided { 111 | border-top: 1px solid var(--off-white); 112 | } 113 | /* 114 | Change color of the anchor links on hover/focus. 115 | */ 116 | #menu .pure-menu li a:hover, 117 | #menu .pure-menu li a:focus { 118 | background: var(--light-blue); 119 | color: var(--black); 120 | } 121 | 122 | /* 123 | This styles the selected menu item `
  • `. 124 | */ 125 | #menu .pure-menu-selected, 126 | #menu .pure-menu-heading { 127 | background: var(--pink-lavender); 128 | color: var(--black); 129 | } 130 | /* 131 | This styles a link within a selected menu item `
  • `. 132 | */ 133 | #menu .pure-menu-selected a { 134 | color: var(--black); 135 | } 136 | 137 | /* 138 | This styles the menu heading. 139 | */ 140 | #menu .pure-menu-heading { 141 | font-size: 110%; 142 | color: var(--white); 143 | margin: 0; 144 | } 145 | 146 | /* -- Dynamic Button For Responsive Menu -------------------------------------*/ 147 | 148 | /* 149 | The button to open/close the Menu is custom-made and not part of Pure. Here's 150 | how it works: 151 | */ 152 | 153 | /* 154 | `.menu-link` represents the responsive menu toggle that shows/hides on 155 | small screens. 156 | */ 157 | .menu-link { 158 | position: fixed; 159 | display: block; /* show this only on small screens */ 160 | top: 3.4em; 161 | left: 0; /* "#menu width" */ 162 | background: var(--black); 163 | background: rgba(0,0,0,0.7); 164 | font-size: 10px; /* change this value to increase/decrease button size */ 165 | z-index: 10; 166 | width: 2em; 167 | height: auto; 168 | padding: 2.1em 1.6em; 169 | } 170 | 171 | .menu-link:hover, 172 | .menu-link:focus { 173 | background: var(--black); 174 | } 175 | 176 | .menu-link span { 177 | position: relative; 178 | display: block; 179 | } 180 | 181 | .menu-link span, 182 | .menu-link span:before, 183 | .menu-link span:after { 184 | background-color: var(--white); 185 | pointer-events: none; 186 | width: 100%; 187 | height: 0.2em; 188 | } 189 | 190 | .menu-link span:before, 191 | .menu-link span:after { 192 | position: absolute; 193 | margin-top: -0.6em; 194 | content: " "; 195 | } 196 | 197 | .menu-link span:after { 198 | margin-top: 0.6em; 199 | } 200 | 201 | 202 | /* -- Responsive Styles (Media Queries) ------------------------------------- */ 203 | 204 | /* 205 | Hides the menu at `48em`, but modify this based on your app's needs. 206 | */ 207 | @media (min-width: 48em) { 208 | 209 | .header, 210 | .content { 211 | padding-left: 2em; 212 | padding-right: 2em; 213 | } 214 | 215 | #layout { 216 | padding-left: 95px; /* left col width "#menu" */ 217 | left: 0; 218 | background-color: var(--off-white); 219 | } 220 | #menu { 221 | left: 190px; 222 | } 223 | 224 | .menu-link { 225 | position: fixed; 226 | left: 190px; 227 | display: none; 228 | } 229 | 230 | #layout.active .menu-link { 231 | left: 190px; 232 | } 233 | } 234 | 235 | @media (max-width: 48em) { 236 | /* Only apply this when the window is small. Otherwise, the following 237 | case results in extra padding on the left: 238 | * Make the window small. 239 | * Tap the menu to trigger the active state. 240 | * Make the window large again. 241 | */ 242 | #layout.active { 243 | position: relative; 244 | left: 190px; 245 | } 246 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from 'next/font/google' 2 | import TopBar from '@/ui/top-bar/top-bar' 3 | import { UserProvider } from '@auth0/nextjs-auth0/client' 4 | import React from 'react' 5 | import { Analytics } from '@vercel/analytics/react' 6 | import { getClient } from '@/lib/client' 7 | import { MeDocument } from '@/gql/documents.generated' 8 | import { User } from '@/gql/types.generated' 9 | import { redirect } from 'next/navigation' 10 | import './global-layout.css' 11 | import './design-tokens.css' 12 | import './utilities.css' 13 | import { fetchAccessToken } from '@/lib/util' 14 | 15 | const inter = Inter({ subsets: ['latin'] }) 16 | 17 | export const metadata = { 18 | title: 'Parabolic Bet', 19 | description: 'Where your bet gains go parabolic', 20 | } 21 | 22 | async function getBettingUser(accessToken: string | undefined | null): Promise { 23 | try { 24 | const response = accessToken 25 | ? await getClient(true).query({ 26 | query: MeDocument, 27 | context: { 28 | headers: { 29 | authorization: `Bearer ${accessToken}`, 30 | }, 31 | }, 32 | }) 33 | : null 34 | return response?.data?.me ?? null 35 | } catch (e) { 36 | console.error(e) 37 | return null 38 | } 39 | } 40 | 41 | // @ts-ignore 42 | export default async function RootLayout({ children }: { children: React.ReactNode }) { 43 | const accessToken = await fetchAccessToken() 44 | const me = accessToken ? await getBettingUser(accessToken) : null 45 | 46 | if (accessToken && !me) { 47 | console.info('redirecting to login') 48 | redirect('/api/auth/login') 49 | } 50 | 51 | return ( 52 | 53 | 54 | Parabolic Bet 55 | 59 | 65 | 69 | 73 | 74 | 75 | 76 | 77 | 78 |
    79 |
    {children}
    80 | 81 |
    82 |
    83 | 84 | 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /src/app/utilities.css: -------------------------------------------------------------------------------- 1 | .bg-dark { 2 | background-color: var(--egyptian-blue) 3 | } 4 | 5 | .bg-light { 6 | background-color: var(--light-blue) 7 | } 8 | 9 | .bg-primary { 10 | background-color: var(--peach-yellow) 11 | } 12 | 13 | .bg-secondary { 14 | background-color: var(--pink-lavender) 15 | } 16 | 17 | .bg-danger { 18 | background-color: var(--salmon) 19 | } 20 | 21 | .text-dark { 22 | color: var(--black) 23 | } 24 | 25 | .text-mid { 26 | background-color: var(--off-white) 27 | } 28 | 29 | .text-light { 30 | color: var(--white) 31 | } 32 | 33 | .text-danger { 34 | color: var(--salmon) 35 | } 36 | 37 | .text-secondary { 38 | color: var(--light-blue) 39 | } 40 | 41 | .text-primary { 42 | color: var(--egyptian-blue) 43 | } -------------------------------------------------------------------------------- /src/gql/documents.generated.ts: -------------------------------------------------------------------------------- 1 | import * as Types from './types.generated'; 2 | 3 | import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 4 | export type MarketFragment = { __typename?: 'Market', id: string, name: string, source: string, lastUpdated?: string | null, isLive: boolean, options?: Array<{ __typename?: 'MarketOption', id: string, name: string, odds: number, point?: number | null, description?: string | null } | null> | null }; 5 | 6 | export type EventFragment = { __typename?: 'Event', id: string, externalId?: string | null, name: string, startTime: string, homeTeamName: string, awayTeamName: string, result?: Types.EventResult | null, isLive: boolean, completed: boolean, sport: { __typename?: 'Sport', id: string, key: string, title: string, description: string, active: boolean, group: string, hasOutrights: boolean }, markets?: Array<{ __typename?: 'Market', id: string, name: string, source: string, lastUpdated?: string | null, isLive: boolean, options?: Array<{ __typename?: 'MarketOption', id: string, name: string, odds: number, point?: number | null, description?: string | null } | null> | null } | null> | null, scoreUpdates?: Array<{ __typename?: 'ScoreUpdate', id: string, name: string, score: string } | null> | null }; 7 | 8 | export type SportFragment = { __typename?: 'Sport', id: string, title: string, active: boolean, group: string, description: string, hasOutrights: boolean }; 9 | 10 | export type MeQueryVariables = Types.Exact<{ [key: string]: never; }>; 11 | 12 | 13 | export type MeQuery = { __typename?: 'Query', me?: { __typename?: 'User', id: string, externalId?: string | null, username?: string | null, email?: string | null, wallet?: { __typename?: 'Wallet', id: string, balance: number } | null } | null }; 14 | 15 | export type ListLiveEventsQueryVariables = Types.Exact<{ [key: string]: never; }>; 16 | 17 | 18 | export type ListLiveEventsQuery = { __typename?: 'Query', listLiveEvents?: Array<{ __typename?: 'Event', id: string, externalId?: string | null, name: string, startTime: string, homeTeamName: string, awayTeamName: string, result?: Types.EventResult | null, isLive: boolean, completed: boolean, sport: { __typename?: 'Sport', id: string, key: string, title: string, description: string, active: boolean, group: string, hasOutrights: boolean }, markets?: Array<{ __typename?: 'Market', id: string, name: string, source: string, lastUpdated?: string | null, isLive: boolean, options?: Array<{ __typename?: 'MarketOption', id: string, name: string, odds: number, point?: number | null, description?: string | null } | null> | null } | null> | null, scoreUpdates?: Array<{ __typename?: 'ScoreUpdate', id: string, name: string, score: string } | null> | null } | null> | null }; 19 | 20 | export type ListEventsBySportQueryVariables = Types.Exact<{ 21 | group: Types.Scalars['String']; 22 | }>; 23 | 24 | 25 | export type ListEventsBySportQuery = { __typename?: 'Query', eventsBySportGroup?: Array<{ __typename?: 'Event', id: string, externalId?: string | null, name: string, startTime: string, homeTeamName: string, awayTeamName: string, result?: Types.EventResult | null, isLive: boolean, completed: boolean, sport: { __typename?: 'Sport', id: string, key: string, title: string, description: string, active: boolean, group: string, hasOutrights: boolean }, markets?: Array<{ __typename?: 'Market', id: string, name: string, source: string, lastUpdated?: string | null, isLive: boolean, options?: Array<{ __typename?: 'MarketOption', id: string, name: string, odds: number, point?: number | null, description?: string | null } | null> | null } | null> | null, scoreUpdates?: Array<{ __typename?: 'ScoreUpdate', id: string, name: string, score: string } | null> | null } | null> | null }; 26 | 27 | export type ListBetsQueryVariables = Types.Exact<{ [key: string]: never; }>; 28 | 29 | 30 | export type ListBetsQuery = { __typename?: 'Query', listBets?: Array<{ __typename?: 'Bet', id: string, stake: number, potentialWinnings: number, createdAt: string, status: Types.BetStatus, betOptions?: Array<{ __typename?: 'BetOption', id: number, status?: Types.BetStatus | null, marketOption: { __typename?: 'MarketOption', id: string, name: string, odds: number, point?: number | null, description?: string | null, market?: { __typename?: 'Market', id: string, name: string, event?: { __typename?: 'Event', id: string, externalId?: string | null, name: string, startTime: string, homeTeamName: string, awayTeamName: string, result?: Types.EventResult | null, isLive: boolean, completed: boolean, sport: { __typename?: 'Sport', id: string, key: string, title: string, description: string, active: boolean, group: string, hasOutrights: boolean }, markets?: Array<{ __typename?: 'Market', id: string, name: string, source: string, lastUpdated?: string | null, isLive: boolean, options?: Array<{ __typename?: 'MarketOption', id: string, name: string, odds: number, point?: number | null, description?: string | null } | null> | null } | null> | null, scoreUpdates?: Array<{ __typename?: 'ScoreUpdate', id: string, name: string, score: string } | null> | null } | null } | null } } | null> | null } | null> | null }; 31 | 32 | export type ListSportsQueryVariables = Types.Exact<{ [key: string]: never; }>; 33 | 34 | 35 | export type ListSportsQuery = { __typename?: 'Query', listSports?: Array<{ __typename?: 'Sport', id: string, title: string, active: boolean, group: string, description: string, hasOutrights: boolean } | null> | null }; 36 | 37 | export type SportWithEventsFragment = { __typename?: 'Sport', id: string, title: string, active: boolean, group: string, description: string, hasOutrights: boolean, events?: Array<{ __typename?: 'Event', id: string, externalId?: string | null, name: string, startTime: string, homeTeamName: string, awayTeamName: string, result?: Types.EventResult | null, isLive: boolean, completed: boolean, sport: { __typename?: 'Sport', id: string, key: string, title: string, description: string, active: boolean, group: string, hasOutrights: boolean }, markets?: Array<{ __typename?: 'Market', id: string, name: string, source: string, lastUpdated?: string | null, isLive: boolean, options?: Array<{ __typename?: 'MarketOption', id: string, name: string, odds: number, point?: number | null, description?: string | null } | null> | null } | null> | null, scoreUpdates?: Array<{ __typename?: 'ScoreUpdate', id: string, name: string, score: string } | null> | null } | null> | null }; 38 | 39 | export type ListSportsWithEventsQueryVariables = Types.Exact<{ 40 | group: Types.Scalars['String']; 41 | }>; 42 | 43 | 44 | export type ListSportsWithEventsQuery = { __typename?: 'Query', listSports?: Array<{ __typename?: 'Sport', id: string, title: string, active: boolean, group: string, description: string, hasOutrights: boolean, events?: Array<{ __typename?: 'Event', id: string, externalId?: string | null, name: string, startTime: string, homeTeamName: string, awayTeamName: string, result?: Types.EventResult | null, isLive: boolean, completed: boolean, sport: { __typename?: 'Sport', id: string, key: string, title: string, description: string, active: boolean, group: string, hasOutrights: boolean }, markets?: Array<{ __typename?: 'Market', id: string, name: string, source: string, lastUpdated?: string | null, isLive: boolean, options?: Array<{ __typename?: 'MarketOption', id: string, name: string, odds: number, point?: number | null, description?: string | null } | null> | null } | null> | null, scoreUpdates?: Array<{ __typename?: 'ScoreUpdate', id: string, name: string, score: string } | null> | null } | null> | null } | null> | null }; 45 | 46 | export type MarketOptionUpdatesSubscriptionVariables = Types.Exact<{ [key: string]: never; }>; 47 | 48 | 49 | export type MarketOptionUpdatesSubscription = { __typename?: 'Subscription', liveMarketOptionsUpdated?: Array<{ __typename?: 'MarketOption', id: string, name: string, odds: number, lastUpdated?: string | null } | null> | null }; 50 | 51 | export type ScoreUpdatesSubscriptionVariables = Types.Exact<{ [key: string]: never; }>; 52 | 53 | 54 | export type ScoreUpdatesSubscription = { __typename?: 'Subscription', eventScoresUpdated?: Array<{ __typename?: 'Event', id: string, externalId?: string | null, name: string, startTime: string, homeTeamName: string, awayTeamName: string, result?: Types.EventResult | null, isLive: boolean, completed: boolean, sport: { __typename?: 'Sport', id: string, key: string, title: string, description: string, active: boolean, group: string, hasOutrights: boolean }, markets?: Array<{ __typename?: 'Market', id: string, name: string, source: string, lastUpdated?: string | null, isLive: boolean, options?: Array<{ __typename?: 'MarketOption', id: string, name: string, odds: number, point?: number | null, description?: string | null } | null> | null } | null> | null, scoreUpdates?: Array<{ __typename?: 'ScoreUpdate', id: string, name: string, score: string } | null> | null } | null> | null }; 55 | 56 | export type EventStatusUpdatesSubscriptionVariables = Types.Exact<{ [key: string]: never; }>; 57 | 58 | 59 | export type EventStatusUpdatesSubscription = { __typename?: 'Subscription', eventStatusUpdated?: Array<{ __typename?: 'Event', id: string, externalId?: string | null, name: string, startTime: string, homeTeamName: string, awayTeamName: string, result?: Types.EventResult | null, isLive: boolean, completed: boolean, sport: { __typename?: 'Sport', id: string, key: string, title: string, description: string, active: boolean, group: string, hasOutrights: boolean }, markets?: Array<{ __typename?: 'Market', id: string, name: string, source: string, lastUpdated?: string | null, isLive: boolean, options?: Array<{ __typename?: 'MarketOption', id: string, name: string, odds: number, point?: number | null, description?: string | null } | null> | null } | null> | null, scoreUpdates?: Array<{ __typename?: 'ScoreUpdate', id: string, name: string, score: string } | null> | null } | null> | null }; 60 | 61 | export type PlaceBetMutationVariables = Types.Exact<{ 62 | betType: Types.BetType; 63 | marketOptions: Array | Types.Scalars['ID']; 64 | stake: Types.Scalars['Float']; 65 | }>; 66 | 67 | 68 | export type PlaceBetMutation = { __typename?: 'Mutation', placeBet?: { __typename?: 'Bet', id: string, status: Types.BetStatus } | null }; 69 | 70 | export type PlaceSingleBetsMutationVariables = Types.Exact<{ 71 | options: Array | Types.BetOptionInput; 72 | }>; 73 | 74 | 75 | export type PlaceSingleBetsMutation = { __typename?: 'Mutation', placeSingleBets?: Array<{ __typename?: 'Bet', id: string, status: Types.BetStatus } | null> | null }; 76 | 77 | export const SportFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Sport"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Sport"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}}]} as unknown as DocumentNode; 78 | export const MarketFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Market"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Market"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"source"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]} as unknown as DocumentNode; 79 | export const EventFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Event"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Event"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startTime"}},{"kind":"Field","name":{"kind":"Name","value":"homeTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"awayTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"sport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"markets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Market"}}]}},{"kind":"Field","name":{"kind":"Name","value":"scoreUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"score"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Market"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Market"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"source"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]} as unknown as DocumentNode; 80 | export const SportWithEventsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SportWithEvents"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Sport"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Sport"}},{"kind":"Field","name":{"kind":"Name","value":"events"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Event"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Market"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Market"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"source"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Sport"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Sport"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Event"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Event"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startTime"}},{"kind":"Field","name":{"kind":"Name","value":"homeTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"awayTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"sport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"markets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Market"}}]}},{"kind":"Field","name":{"kind":"Name","value":"scoreUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"score"}}]}}]}}]} as unknown as DocumentNode; 81 | export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"wallet"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"balance"}}]}}]}}]}}]} as unknown as DocumentNode; 82 | export const ListLiveEventsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListLiveEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"listLiveEvents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Event"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Market"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Market"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"source"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Event"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Event"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startTime"}},{"kind":"Field","name":{"kind":"Name","value":"homeTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"awayTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"sport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"markets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Market"}}]}},{"kind":"Field","name":{"kind":"Name","value":"scoreUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"score"}}]}}]}}]} as unknown as DocumentNode; 83 | export const ListEventsBySportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListEventsBySport"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"group"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventsBySportGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"group"},"value":{"kind":"Variable","name":{"kind":"Name","value":"group"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Event"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Market"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Market"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"source"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Event"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Event"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startTime"}},{"kind":"Field","name":{"kind":"Name","value":"homeTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"awayTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"sport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"markets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Market"}}]}},{"kind":"Field","name":{"kind":"Name","value":"scoreUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"score"}}]}}]}}]} as unknown as DocumentNode; 84 | export const ListBetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListBets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"listBets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"betOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"marketOption"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"market"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"event"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Event"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"stake"}},{"kind":"Field","name":{"kind":"Name","value":"potentialWinnings"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Market"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Market"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"source"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Event"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Event"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startTime"}},{"kind":"Field","name":{"kind":"Name","value":"homeTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"awayTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"sport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"markets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Market"}}]}},{"kind":"Field","name":{"kind":"Name","value":"scoreUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"score"}}]}}]}}]} as unknown as DocumentNode; 85 | export const ListSportsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"listSports"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"listSports"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"group"},"value":{"kind":"StringValue","value":"all","block":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Sport"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Sport"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Sport"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}}]} as unknown as DocumentNode; 86 | export const ListSportsWithEventsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"listSportsWithEvents"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"group"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"listSports"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"group"},"value":{"kind":"Variable","name":{"kind":"Name","value":"group"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SportWithEvents"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Sport"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Sport"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Market"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Market"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"source"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Event"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Event"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startTime"}},{"kind":"Field","name":{"kind":"Name","value":"homeTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"awayTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"sport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"markets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Market"}}]}},{"kind":"Field","name":{"kind":"Name","value":"scoreUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"score"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SportWithEvents"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Sport"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Sport"}},{"kind":"Field","name":{"kind":"Name","value":"events"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Event"}}]}}]}}]} as unknown as DocumentNode; 87 | export const MarketOptionUpdatesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"marketOptionUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"liveMarketOptionsUpdated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}}]}}]}}]} as unknown as DocumentNode; 88 | export const ScoreUpdatesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"scoreUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventScoresUpdated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Event"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Market"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Market"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"source"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Event"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Event"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startTime"}},{"kind":"Field","name":{"kind":"Name","value":"homeTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"awayTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"sport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"markets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Market"}}]}},{"kind":"Field","name":{"kind":"Name","value":"scoreUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"score"}}]}}]}}]} as unknown as DocumentNode; 89 | export const EventStatusUpdatesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"eventStatusUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventStatusUpdated"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Event"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Market"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Market"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"source"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdated"}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"odds"}},{"kind":"Field","name":{"kind":"Name","value":"point"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Event"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Event"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startTime"}},{"kind":"Field","name":{"kind":"Name","value":"homeTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"awayTeamName"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"sport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"hasOutrights"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isLive"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"markets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Market"}}]}},{"kind":"Field","name":{"kind":"Name","value":"scoreUpdates"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"score"}}]}}]}}]} as unknown as DocumentNode; 90 | export const PlaceBetDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"placeBet"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"betType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BetType"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"marketOptions"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stake"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"placeBet"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"betType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"betType"}}},{"kind":"Argument","name":{"kind":"Name","value":"marketOptions"},"value":{"kind":"Variable","name":{"kind":"Name","value":"marketOptions"}}},{"kind":"Argument","name":{"kind":"Name","value":"stake"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stake"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; 91 | export const PlaceSingleBetsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"placeSingleBets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"options"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BetOptionInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"placeSingleBets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"options"},"value":{"kind":"Variable","name":{"kind":"Name","value":"options"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]} as unknown as DocumentNode; -------------------------------------------------------------------------------- /src/gql/documents.graphql: -------------------------------------------------------------------------------- 1 | fragment Market on Market { 2 | id 3 | name 4 | source 5 | lastUpdated 6 | isLive 7 | options { 8 | id 9 | name 10 | odds 11 | point 12 | description 13 | } 14 | } 15 | 16 | fragment Event on Event { 17 | id 18 | externalId 19 | name 20 | startTime 21 | homeTeamName 22 | awayTeamName 23 | result 24 | sport { 25 | id 26 | key 27 | title 28 | description 29 | active 30 | group 31 | hasOutrights 32 | } 33 | isLive 34 | completed 35 | markets { 36 | ...Market 37 | } 38 | scoreUpdates { 39 | id 40 | name 41 | score 42 | } 43 | } 44 | 45 | fragment EventWithoutSport on Event { 46 | id 47 | externalId 48 | name 49 | startTime 50 | homeTeamName 51 | awayTeamName 52 | result 53 | isLive 54 | completed 55 | markets { 56 | ...Market 57 | } 58 | scoreUpdates { 59 | id 60 | name 61 | score 62 | } 63 | } 64 | 65 | fragment Sport on Sport { 66 | id 67 | title 68 | active 69 | group 70 | description 71 | hasOutrights 72 | } 73 | 74 | query me { 75 | me { 76 | id 77 | externalId 78 | username 79 | email 80 | wallet { 81 | id 82 | balance 83 | } 84 | } 85 | } 86 | 87 | query ListLiveEvents { 88 | listLiveEvents { 89 | ...Event 90 | } 91 | } 92 | 93 | query ListEventsBySport($group: String!) { 94 | eventsBySportGroup(group: $group) { 95 | ...Event 96 | } 97 | } 98 | 99 | 100 | query ListBets { 101 | listBets { 102 | id 103 | betOptions { 104 | id 105 | status 106 | marketOption { 107 | id 108 | name 109 | odds 110 | point 111 | description 112 | market { 113 | id 114 | name 115 | event { 116 | ...Event 117 | } 118 | } 119 | } 120 | } 121 | stake 122 | potentialWinnings 123 | createdAt 124 | status 125 | } 126 | } 127 | 128 | query listSports { 129 | listSports(group: "all") { 130 | ...Sport 131 | } 132 | } 133 | 134 | fragment SportWithEvents on Sport { 135 | ...Sport 136 | events { 137 | ...EventWithoutSport 138 | } 139 | } 140 | 141 | query listSportsWithEvents($group: String!) { 142 | listSports(group: $group) { 143 | ...SportWithEvents 144 | } 145 | } 146 | 147 | subscription marketOptionUpdates { 148 | liveMarketOptionsUpdated { 149 | id 150 | name 151 | odds 152 | lastUpdated 153 | } 154 | } 155 | 156 | subscription scoreUpdates { 157 | eventScoresUpdated { 158 | ...Event 159 | } 160 | } 161 | 162 | subscription eventStatusUpdates { 163 | eventStatusUpdated { 164 | ...Event 165 | } 166 | } 167 | 168 | mutation placeBet($betType: BetType!, $marketOptions: [ID!]!, $stake: Float!) { 169 | placeBet(betType: $betType, marketOptions: $marketOptions, stake: $stake) { 170 | id 171 | status 172 | } 173 | } 174 | 175 | mutation placeSingleBets($options: [BetOptionInput!]!) { 176 | placeSingleBets(options: $options) { 177 | id 178 | status 179 | } 180 | } 181 | 182 | -------------------------------------------------------------------------------- /src/gql/types.generated.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = T | null; 2 | export type InputMaybe = Maybe; 3 | export type Exact = { [K in keyof T]: T[K] }; 4 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 5 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 6 | /** All built-in and custom scalars, mapped to their actual values */ 7 | export type Scalars = { 8 | ID: string; 9 | String: string; 10 | Boolean: boolean; 11 | Int: number; 12 | Float: number; 13 | DateTime: any; 14 | }; 15 | 16 | export type Bet = { 17 | __typename?: 'Bet'; 18 | betOptions?: Maybe>>; 19 | createdAt: Scalars['String']; 20 | id: Scalars['ID']; 21 | potentialWinnings: Scalars['Float']; 22 | stake: Scalars['Float']; 23 | status: BetStatus; 24 | user?: Maybe; 25 | }; 26 | 27 | export type BetOption = { 28 | __typename?: 'BetOption'; 29 | bet: Bet; 30 | id: Scalars['Int']; 31 | marketOption: MarketOption; 32 | status?: Maybe; 33 | }; 34 | 35 | export type BetOptionInput = { 36 | marketOptionId: Scalars['ID']; 37 | stake: Scalars['Float']; 38 | }; 39 | 40 | export enum BetStatus { 41 | Canceled = 'CANCELED', 42 | Lost = 'LOST', 43 | Pending = 'PENDING', 44 | Won = 'WON' 45 | } 46 | 47 | /** https://chat.openai.com/share/92b7bc9f-6fc6-4f57-9a4e-f217270ad271 */ 48 | export enum BetType { 49 | Parlay = 'PARLAY', 50 | Single = 'SINGLE', 51 | /** same as long bet or accumulator */ 52 | System = 'SYSTEM' 53 | } 54 | 55 | /** An Event represents a sports match or competition on which users can place bets. */ 56 | export type Event = { 57 | __typename?: 'Event'; 58 | awayTeamName: Scalars['String']; 59 | /** Is this event completed? */ 60 | completed: Scalars['Boolean']; 61 | /** The id of the event in the source system. */ 62 | externalId?: Maybe; 63 | homeTeamName: Scalars['String']; 64 | id: Scalars['ID']; 65 | /** Is this event currently live? */ 66 | isLive: Scalars['Boolean']; 67 | markets?: Maybe>>; 68 | name: Scalars['String']; 69 | result?: Maybe; 70 | scoreUpdates?: Maybe>>; 71 | sport: Sport; 72 | startTime: Scalars['String']; 73 | }; 74 | 75 | 76 | /** An Event represents a sports match or competition on which users can place bets. */ 77 | export type EventMarketsArgs = { 78 | source?: InputMaybe; 79 | }; 80 | 81 | export enum EventResult { 82 | AwayTeamWin = 'AWAY_TEAM_WIN', 83 | Draw = 'DRAW', 84 | HomeTeamWin = 'HOME_TEAM_WIN' 85 | } 86 | 87 | /** 88 | * A Market represents a specific betting opportunity within an event. 89 | * It's usually associated with one aspect of the event that users can bet on. 90 | * A single event can have multiple markets. Some common examples of markets 91 | * include Moneyline, Point Spread, and Totals (Over/Under). 92 | */ 93 | export type Market = { 94 | __typename?: 'Market'; 95 | event?: Maybe; 96 | id: Scalars['ID']; 97 | /** Is this Market available for live betting? */ 98 | isLive: Scalars['Boolean']; 99 | /** 100 | * When was this Market last updated? Used to track when the odds were last 101 | * updated during live betting. 102 | */ 103 | lastUpdated?: Maybe; 104 | name: Scalars['String']; 105 | options?: Maybe>>; 106 | /** What is the source or bookmaker that provides the odds for this Market? */ 107 | source: Scalars['String']; 108 | }; 109 | 110 | /** 111 | * A MarketOption represents a specific choice or outcome within a market 112 | * that users can bet on. Each market typically has two or more options to choose from. 113 | */ 114 | export type MarketOption = { 115 | __typename?: 'MarketOption'; 116 | description?: Maybe; 117 | id: Scalars['ID']; 118 | /** 119 | * When was this Market last updated? Used to track when the odds were last 120 | * updated during live betting. 121 | */ 122 | lastUpdated?: Maybe; 123 | market?: Maybe; 124 | name: Scalars['String']; 125 | odds: Scalars['Float']; 126 | point?: Maybe; 127 | }; 128 | 129 | /** Mutations */ 130 | export type Mutation = { 131 | __typename?: 'Mutation'; 132 | /** should be available for admins only: */ 133 | createEvent?: Maybe; 134 | createMarket?: Maybe; 135 | createMarketOption?: Maybe; 136 | createUser?: Maybe; 137 | depositFunds?: Maybe; 138 | /** Places a bet on the provided market options. */ 139 | placeBet?: Maybe; 140 | /** Place multiple single bets, one for each option provided. */ 141 | placeSingleBets?: Maybe>>; 142 | updateResult?: Maybe; 143 | withdrawFunds?: Maybe; 144 | }; 145 | 146 | 147 | /** Mutations */ 148 | export type MutationCreateEventArgs = { 149 | name: Scalars['String']; 150 | sport: Scalars['String']; 151 | startTime: Scalars['String']; 152 | }; 153 | 154 | 155 | /** Mutations */ 156 | export type MutationCreateMarketArgs = { 157 | eventId: Scalars['ID']; 158 | name: Scalars['String']; 159 | }; 160 | 161 | 162 | /** Mutations */ 163 | export type MutationCreateMarketOptionArgs = { 164 | marketId: Scalars['ID']; 165 | name: Scalars['String']; 166 | odds: Scalars['Float']; 167 | }; 168 | 169 | 170 | /** Mutations */ 171 | export type MutationCreateUserArgs = { 172 | email: Scalars['String']; 173 | username: Scalars['String']; 174 | }; 175 | 176 | 177 | /** Mutations */ 178 | export type MutationDepositFundsArgs = { 179 | amount: Scalars['Float']; 180 | userId: Scalars['ID']; 181 | }; 182 | 183 | 184 | /** Mutations */ 185 | export type MutationPlaceBetArgs = { 186 | betType: BetType; 187 | marketOptions: Array; 188 | stake: Scalars['Float']; 189 | }; 190 | 191 | 192 | /** Mutations */ 193 | export type MutationPlaceSingleBetsArgs = { 194 | options: Array; 195 | }; 196 | 197 | 198 | /** Mutations */ 199 | export type MutationUpdateResultArgs = { 200 | eventId: Scalars['ID']; 201 | }; 202 | 203 | 204 | /** Mutations */ 205 | export type MutationWithdrawFundsArgs = { 206 | amount: Scalars['Float']; 207 | userId: Scalars['ID']; 208 | }; 209 | 210 | /** Queries */ 211 | export type Query = { 212 | __typename?: 'Query'; 213 | /** List events available for betting in the specified sport group. Lists both live an upcoming events. */ 214 | eventsBySportGroup?: Maybe>>; 215 | getBet?: Maybe; 216 | getEvent?: Maybe; 217 | getMarket?: Maybe; 218 | /** List all events available for betting. Lists both live an upcoming events. */ 219 | listAllEvents?: Maybe>>; 220 | listBets?: Maybe>>; 221 | /** List upcoming events available for betting. */ 222 | listEvents?: Maybe>>; 223 | /** List live events available for betting. */ 224 | listLiveEvents?: Maybe>>; 225 | listLiveMarkets?: Maybe>>; 226 | listMarkets?: Maybe>>; 227 | /** List sports by group. */ 228 | listSports?: Maybe>>; 229 | me?: Maybe; 230 | }; 231 | 232 | 233 | /** Queries */ 234 | export type QueryEventsBySportGroupArgs = { 235 | group: Scalars['String']; 236 | }; 237 | 238 | 239 | /** Queries */ 240 | export type QueryGetBetArgs = { 241 | id: Scalars['ID']; 242 | }; 243 | 244 | 245 | /** Queries */ 246 | export type QueryGetEventArgs = { 247 | id: Scalars['ID']; 248 | }; 249 | 250 | 251 | /** Queries */ 252 | export type QueryGetMarketArgs = { 253 | id: Scalars['ID']; 254 | }; 255 | 256 | 257 | /** Queries */ 258 | export type QueryListLiveMarketsArgs = { 259 | eventId: Scalars['ID']; 260 | }; 261 | 262 | 263 | /** Queries */ 264 | export type QueryListMarketsArgs = { 265 | eventId: Scalars['ID']; 266 | }; 267 | 268 | 269 | /** Queries */ 270 | export type QueryListSportsArgs = { 271 | group?: InputMaybe; 272 | }; 273 | 274 | export type ScoreUpdate = { 275 | __typename?: 'ScoreUpdate'; 276 | event: Event; 277 | id: Scalars['ID']; 278 | name: Scalars['String']; 279 | score: Scalars['String']; 280 | timestamp: Scalars['String']; 281 | }; 282 | 283 | export type Sport = { 284 | __typename?: 'Sport'; 285 | /** Is this sport in season at the moment? */ 286 | active: Scalars['Boolean']; 287 | activeEventCount?: Maybe; 288 | description: Scalars['String']; 289 | /** List all events available for betting in this sport. */ 290 | events?: Maybe>>; 291 | group: Scalars['String']; 292 | /** Does this sport have outright markets? */ 293 | hasOutrights: Scalars['Boolean']; 294 | id: Scalars['ID']; 295 | key: Scalars['String']; 296 | title: Scalars['String']; 297 | }; 298 | 299 | export type Subscription = { 300 | __typename?: 'Subscription'; 301 | eventScoresUpdated?: Maybe>>; 302 | eventStatusUpdated?: Maybe>>; 303 | liveMarketOptionsUpdated?: Maybe>>; 304 | }; 305 | 306 | export type Transaction = { 307 | __typename?: 'Transaction'; 308 | amount: Scalars['Float']; 309 | createdAt: Scalars['String']; 310 | id: Scalars['ID']; 311 | transactionType: TransactionType; 312 | wallet?: Maybe; 313 | }; 314 | 315 | export enum TransactionType { 316 | BetPlaced = 'BET_PLACED', 317 | BetRefunded = 'BET_REFUNDED', 318 | BetWon = 'BET_WON', 319 | Deposit = 'DEPOSIT', 320 | Withdrawal = 'WITHDRAWAL' 321 | } 322 | 323 | export type User = { 324 | __typename?: 'User'; 325 | bets?: Maybe>>; 326 | email?: Maybe; 327 | externalId?: Maybe; 328 | id: Scalars['ID']; 329 | username?: Maybe; 330 | wallet?: Maybe; 331 | }; 332 | 333 | export type Wallet = { 334 | __typename?: 'Wallet'; 335 | balance: Scalars['Float']; 336 | id: Scalars['ID']; 337 | transactions?: Maybe>>; 338 | user?: Maybe; 339 | }; 340 | -------------------------------------------------------------------------------- /src/lib/apollo-link.ts: -------------------------------------------------------------------------------- 1 | import { HttpLink, split } from '@apollo/client' 2 | import { GraphQLWsLink } from '@apollo/client/link/subscriptions' 3 | import { createClient } from 'graphql-ws' 4 | import { getMainDefinition } from '@apollo/client/utilities' 5 | 6 | export const wsLink = new GraphQLWsLink( 7 | createClient({ 8 | url: `${process.env.NEXT_PUBLIC_API_URL}/subscriptions`.replace(/^https*/, 'wss'), 9 | }) 10 | ) 11 | export const splitLink = (httpLink: HttpLink) => 12 | split( 13 | ({ query }) => { 14 | const definition = getMainDefinition(query) 15 | return definition.kind === 'OperationDefinition' && definition.operation === 'subscription' 16 | }, 17 | wsLink, 18 | httpLink 19 | ) 20 | -------------------------------------------------------------------------------- /src/lib/client.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client' 2 | import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc' 3 | import { splitLink } from '@/lib/apollo-link' 4 | // @ts-ignore 5 | 6 | // TODO: this creates a new client (and new cache) every time, fix that 7 | export const getClient = (isAuthenticated: boolean = false) => { 8 | const { getClient } = registerApolloClient(() => { 9 | const uri = 10 | (isAuthenticated 11 | ? process.env.NEXT_PUBLIC_BETTING_API_URL 12 | : process.env.NEXT_PUBLIC_API_URL) + '/graphql' 13 | console.log(`uri, is authenticated? ${isAuthenticated}`, uri) 14 | const httpLink = new HttpLink({ 15 | uri, 16 | }) 17 | return new ApolloClient({ 18 | cache: new InMemoryCache(), 19 | link: splitLink(httpLink), 20 | }) 21 | }) 22 | return getClient() 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/slip-context.tsx: -------------------------------------------------------------------------------- 1 | import useSlip, { PlaceBetResponse } from '@/lib/useSlip' 2 | import React from 'react' 3 | import { BetSlipOption } from '@/ui/bet-slip/bet-slip' 4 | import { MarketOption } from '@/gql/types.generated' 5 | 6 | export type SlipType = { 7 | options: BetSlipOption[] 8 | refetchSlip: () => void 9 | loading: boolean 10 | removeOption: (optionId: string) => Promise 11 | addOption: (option: BetSlipOption) => Promise 12 | postBet: (singles: BetSlipOption[], long: BetSlipOption | null) => Promise 13 | } 14 | 15 | export const SlipContext = React.createContext(null) 16 | 17 | export const SlipProvider = ({ children }: { children: React.ReactNode }) => { 18 | const slipState = useSlip() 19 | 20 | return {children} 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/useSlip.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { BetSlipOption, Slip } from '@/ui/bet-slip/bet-slip' 3 | import { kv } from '@vercel/kv' 4 | import { UserContext, UserProfile, useUser } from '@auth0/nextjs-auth0/client' 5 | import { MarketOption } from '@/gql/types.generated' 6 | import { SlipType } from '@/lib/slip-context' 7 | 8 | async function loadSlip(): Promise { 9 | console.log('loading betslip') 10 | const response = await fetch('/api/slip') 11 | const data = await response.json() 12 | console.log('loaded slip', data) 13 | return data.slip 14 | } 15 | 16 | async function removeSlipOption(optionId: string) { 17 | console.log('removing slip option', optionId) 18 | const response = await fetch(`/api/slip-options/${optionId}`, { 19 | method: 'DELETE', 20 | }) 21 | const data = await response.json() 22 | console.log('removed slip option', data) 23 | } 24 | 25 | async function addSlipOption(option: BetSlipOption) { 26 | console.log('adding slip option', option) 27 | const response = await fetch('/api/slip-options', { 28 | method: 'POST', 29 | body: JSON.stringify(option), 30 | }) 31 | const data = await response.json() 32 | console.log('added slip option', data) 33 | } 34 | 35 | export type PlaceBetResponse = { 36 | singles: BetSlipOption[] 37 | long: BetSlipOption | null 38 | } 39 | 40 | async function placeBet( 41 | singles: BetSlipOption[], 42 | long: BetSlipOption | null 43 | ): Promise { 44 | console.log('placing bet', singles, long) 45 | const response = await fetch('/api/slip', { 46 | method: 'POST', 47 | body: JSON.stringify({ singles, long }), 48 | }) 49 | return response.json() 50 | } 51 | 52 | const useSlip = (): SlipType => { 53 | const { user } = useUser() 54 | const [loading, setIsLoading] = useState(false) 55 | const [options, setOptions] = useState([]) 56 | 57 | useEffect(() => { 58 | if (!user) { 59 | return 60 | } 61 | const fetchData = async () => { 62 | setIsLoading(true) 63 | try { 64 | const newData = await loadSlip() 65 | if (newData) { 66 | setOptions(newData ? Object.values(newData) : []) 67 | } 68 | } finally { 69 | setIsLoading(false) 70 | } 71 | } 72 | fetchData() 73 | }, [user]) 74 | 75 | const refetchSlip = async () => { 76 | if (!user) { 77 | return 78 | } 79 | const slip = await loadSlip() 80 | setOptions(Object.values(slip)) 81 | } 82 | 83 | const removeOption = async (optionId: string) => { 84 | console.log('removing option', optionId) 85 | setIsLoading(true) 86 | await removeSlipOption(optionId) 87 | // delete optionId from slip 88 | const newSlip = options.filter((option) => option.id !== optionId) 89 | 90 | console.log('new slip', newSlip) 91 | console.log('Is slip changed?', newSlip !== options) 92 | 93 | setOptions(newSlip) 94 | await refetchSlip() 95 | setIsLoading(false) 96 | } 97 | 98 | const addOption = async (option: BetSlipOption) => { 99 | setIsLoading(true) 100 | await addSlipOption(option) 101 | await refetchSlip() 102 | setIsLoading(false) 103 | } 104 | 105 | const postBet = async ( 106 | singles: BetSlipOption[], 107 | long: BetSlipOption | null 108 | ): Promise => { 109 | setIsLoading(true) 110 | const response = await placeBet(singles, long) 111 | setOptions([]) 112 | setIsLoading(false) 113 | return response 114 | } 115 | 116 | return { options, refetchSlip, loading, removeOption, addOption, postBet } 117 | } 118 | 119 | export default useSlip 120 | -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | import { getAccessToken } from '@auth0/nextjs-auth0' 2 | 3 | export function getLongBetName(length: number) { 4 | const names = new Map([ 5 | [1, 'Single'], 6 | [2, 'Double'], 7 | [3, 'Treble'], 8 | [4, 'Fourfold'], 9 | [5, 'Fivefold'], 10 | [6, 'Sixfold'], 11 | [7, 'Sevenfold'], 12 | [8, 'Eightfold'], 13 | [9, 'Ninefold'], 14 | [10, 'Tenfold'], 15 | ]) 16 | return names.get(length) ?? `${length}-fold` 17 | } 18 | 19 | export async function fetchAccessToken() { 20 | try { 21 | const { accessToken } = await getAccessToken() 22 | return accessToken 23 | } catch (e) { 24 | return null 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/bet-slip/bet-slip.module.css: -------------------------------------------------------------------------------- 1 | 2 | .betslip { 3 | position: fixed; 4 | bottom: 0; 5 | right: 2em; 6 | width: 400px; 7 | background-color: var(--off-white) !important; 8 | box-shadow: -2px 2px 10px rgba(0, 0, 0, 0.3); 9 | overflow-y: auto; /* Allows scrolling if the content overflows */ 10 | max-height: 100%; /* Ensures it doesn't grow beyond the height of the viewport */ 11 | transition: max-height 0.1s ease-out; 12 | border-radius: var(--radius-large); 13 | padding: var(--spacing-medium); 14 | border-top: 10px solid var(--pink-lavender); 15 | } 16 | 17 | .betSlipTitle { 18 | display: flex; 19 | flex-direction: row; 20 | justify-content: space-between; 21 | align-items: center; 22 | cursor: pointer; 23 | } 24 | 25 | .showAction { 26 | color: var(--egyptian-blue); 27 | } 28 | 29 | .open { 30 | max-height: 80%; /* Example value, you can adjust based on your UI */ 31 | min-height: 200px; 32 | } 33 | 34 | .collapsed { 35 | max-height: 0; 36 | overflow: hidden; 37 | } 38 | 39 | 40 | .visible { 41 | opacity: 1; 42 | } 43 | 44 | .hidden { 45 | opacity: 0; 46 | } 47 | 48 | .header { 49 | padding: 20px; 50 | } 51 | 52 | .closebtn { 53 | position: absolute; 54 | top: 1px; 55 | right: 1px; 56 | border: none; 57 | border-radius: 5px; 58 | cursor: pointer; 59 | font-size: 15px; 60 | height: 28px; 61 | width: 28px; 62 | background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='6' viewBox='0 0 12 12'%3E%3Cpath fill='%23367A65' fill-rule='evenodd' d='M12 .784L11.243 0 6 5.431 .757 0 0 .784l5.243 5.432L6 7l.757-.784z'/%3E%3C/svg%3E") center center no-repeat; 63 | background-size: contain; 64 | } 65 | 66 | .closebtn:hover { 67 | background-color: var(--white); 68 | } 69 | 70 | .optionInfo { 71 | display: flex; 72 | flex-direction: column; 73 | flex-grow: 1; 74 | } 75 | 76 | .options { 77 | padding: 0 0.5rem; 78 | display: flex; 79 | flex-direction: row; 80 | } 81 | 82 | .options .column { 83 | display: flex; 84 | flex-direction: column; 85 | gap: 1rem; 86 | padding: 0 5px; 87 | } 88 | 89 | .options .right { 90 | border-bottom: 1px solid var(--salmon); 91 | } 92 | 93 | .options .option { 94 | display: flex; 95 | flex-direction: row; 96 | gap: 1em; 97 | width: 100%; 98 | font-size: .8rem; 99 | color: #777; 100 | align-items: flex-start; 101 | justify-content: space-between; 102 | height: 70px; 103 | padding-bottom: 1em; 104 | } 105 | 106 | .options .option .header { 107 | display: flex; 108 | flex-direction: row; 109 | justify-content: space-between; 110 | width: 100%; 111 | padding: 0; 112 | } 113 | 114 | .options .option .content { 115 | padding-left: 5px; 116 | width: 100%; 117 | font-weight: normal; 118 | font-size: var(--font-size-caption); 119 | overflow: visible; 120 | } 121 | 122 | .options .marketName { 123 | font-weight: 600; 124 | color: var(--salmon); 125 | margin-bottom: 0.3rem; 126 | } 127 | 128 | .options .option .name { 129 | display: flex; 130 | flex-direction: row; 131 | justify-content: space-between; 132 | flex-grow: 1; 133 | gap: 0.5em; 134 | font-size: var(--font-size-body); 135 | font-weight: 600; 136 | margin-bottom: 0.3rem; 137 | margin-left: 5px; 138 | } 139 | 140 | .iconButton { 141 | border: none; 142 | width: 20px; 143 | height: 20px; 144 | background-color: var(--off-white); 145 | } 146 | 147 | .iconButton:hover { 148 | background-color: var(--white); 149 | } 150 | 151 | input.stake { 152 | width: 4em; 153 | height: 30px; 154 | border: 1px solid var(--salmon); 155 | border-radius: 5px; 156 | padding: 0 5px; 157 | font-size: 1rem; 158 | } 159 | 160 | .actions { 161 | display: flex; 162 | flex-direction: row; 163 | justify-content: space-between; 164 | width: 100%; 165 | padding: 1rem 0; 166 | margin-left: -10px; 167 | } 168 | 169 | .betForm { 170 | display: flex; 171 | flex-direction: column; 172 | flex-grow: 1; 173 | } 174 | 175 | .submitting { 176 | opacity: 0.5; 177 | } 178 | 179 | .empty { 180 | padding: 1em; 181 | font-size: var(--font-size-body); 182 | font-weight: 600; 183 | border-bottom: dashed 4px var(--salmon); 184 | text-align: center; 185 | line-height: 1.5em; 186 | } -------------------------------------------------------------------------------- /src/ui/bet-slip/bet-slip.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useContext, useState } from 'react' 4 | import styles from './bet-slip.module.css' 5 | import { MarketOption } from '@/gql/types.generated' 6 | import { PlaceBetForm } from '@/ui/bet-slip/place-bet-form' 7 | import { EventFragment } from '@/gql/documents.generated' 8 | import classnames from 'classnames' 9 | import { SlipContext } from '@/lib/slip-context' 10 | 11 | export type BetSlipOption = MarketOption & { 12 | stake?: number 13 | marketName: string 14 | event: EventFragment | null 15 | } 16 | 17 | export type Slip = { [key: string]: BetSlipOption } 18 | 19 | const BetSlip = () => { 20 | const [isOpen, setIsOpen] = useState(true) 21 | const slipState = useContext(SlipContext) 22 | const options = slipState?.options ?? [] 23 | 24 | const getSlipTitle = () => { 25 | if (!options) { 26 | return 'Bet Slip' 27 | } 28 | if (options.length === 1) { 29 | return 'Single Bet' 30 | } 31 | if (options.length > 1) { 32 | return 'Long Bet' 33 | } 34 | return 'Bet Slip' 35 | } 36 | 37 | const handleOptionRemoved = (option: MarketOption) => {} 38 | 39 | return ( 40 |
    41 |
    setIsOpen(true)} 44 | > 45 |
    46 | {getSlipTitle()} 47 |
    Show selections ^
    48 |
    49 |
    50 |
    51 | 55 |
    56 |
    57 | 58 |
    59 |
    60 | ) 61 | } 62 | 63 | export default BetSlip 64 | -------------------------------------------------------------------------------- /src/ui/bet-slip/place-bet-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import styles from '@/ui/bet-slip/bet-slip.module.css' 4 | import globals from '@/ui/globals.module.css' 5 | import React, { useContext, useState } from 'react' 6 | import { BetSlipOption } from '@/ui/bet-slip/bet-slip' 7 | import { Bet } from '@/gql/types.generated' 8 | import { RemoveSlipOptionForm } from '@/ui/bet-slip/remove-slip-option-form' 9 | import { useRouter } from 'next/navigation' 10 | import { getLongBetName } from '@/lib/util' 11 | import { getOptionPointLabel, getSpreadOptionLabel } from '@/ui/event-util' 12 | import { SlipContext, SlipType } from '@/lib/slip-context' 13 | import { PlaceBetResponse } from '@/lib/useSlip' 14 | import classnames from 'classnames' 15 | 16 | export type CreatedBets = { 17 | singles: Bet[] 18 | long?: Bet[] 19 | } 20 | 21 | function SubmitButton({ 22 | onClick, 23 | loading, 24 | }: { 25 | onClick: (e: { preventDefault: () => void; stopPropagation: () => void }) => void 26 | loading: boolean 27 | }) { 28 | return ( 29 | 37 | ) 38 | } 39 | 40 | const initialState = { 41 | message: null, 42 | } 43 | 44 | export function PlaceBetForm() { 45 | const slipState: SlipType | null = useContext(SlipContext) 46 | const [stakes, setStakes] = useState>(new Map()) 47 | const [longStake, setLongStake] = useState(null) 48 | 49 | const optionIds = slipState?.options?.map((option) => option.id) ?? [] 50 | const [longOption, setLongOption] = useState(null) 51 | const [createdBetsCount, setCreatedBetsCount] = useState(0) 52 | const router = useRouter() 53 | const [loading, setLoading] = useState(false) 54 | 55 | const getSingles = (): BetSlipOption[] => { 56 | return ( 57 | slipState?.options.map((option) => { 58 | return { 59 | ...option, 60 | stake: stakes.get(option.id) ?? 0, 61 | event: option.market?.event ?? null, 62 | marketName: option.market?.name ?? '', 63 | } 64 | }) ?? [] 65 | ) 66 | } 67 | 68 | const getLongOption = (): BetSlipOption | null => { 69 | return (longStake ?? 0) > 0 70 | ? { 71 | id: 'long', 72 | stake: longStake as number, 73 | event: null, 74 | marketName: '', 75 | name: getLongBetName(optionIds.length), 76 | odds: slipState?.options.reduce((acc, o) => acc * o.odds, 1) ?? 0, 77 | } 78 | : null 79 | } 80 | 81 | const placeBet = (e: { preventDefault: () => void; stopPropagation: () => void }) => { 82 | e.preventDefault() 83 | e.stopPropagation() 84 | setLoading(true) 85 | slipState?.postBet(getSingles(), getLongOption()).then( 86 | (bets: PlaceBetResponse) => { 87 | setLoading(false) 88 | const count = (bets.singles?.length ?? 0) + (bets.long ? 1 : 0) 89 | // router.refresh() 90 | setCreatedBetsCount(count) 91 | 92 | setTimeout(() => { 93 | setCreatedBetsCount(0) 94 | }, 3000) 95 | }, 96 | (error) => { 97 | setLoading(false) 98 | console.error('Error placing bet', error) 99 | } 100 | ) 101 | } 102 | 103 | function renderOption(option: BetSlipOption) { 104 | if (!option) return null 105 | 106 | function renderEventInfo() { 107 | const event = option.event 108 | if (!event) { 109 | return null 110 | } 111 | return ( 112 | <> 113 | {event.homeTeamName} {getSpreadOptionLabel(event, true)}  vs  114 | {event.awayTeamName} 115 | {getSpreadOptionLabel(event, false)} 116 | 117 | ) 118 | } 119 | console.log('option', option) 120 | 121 | return ( 122 |
  • 123 |
    124 |
    125 |
    126 |
    127 | {option.name} {getOptionPointLabel(option, option.market?.name ?? '')} 128 |
    129 |
    {option.odds.toFixed(2)}
    130 |
    131 |
    132 |
    133 |
    {option.marketName}
    134 |
    {renderEventInfo()}
    135 |
    136 |
    137 | { 148 | if (option.id === 'long') { 149 | return setLongStake(e.target.value === '' ? null : Number(e.target.value)) 150 | } 151 | const newStakes = new Map(stakes) 152 | if (e.target.value === '') { 153 | newStakes.delete(option.id) 154 | setStakes(newStakes) 155 | } else { 156 | newStakes.set(option.id, Number(e.target.value)) 157 | setStakes(newStakes) 158 | } 159 | }} 160 | /> 161 |
  • 162 | ) 163 | } 164 | 165 | if (createdBetsCount > 0) { 166 | return ( 167 |

    168 | {createdBetsCount > 1 ? createdBetsCount : 'Your'} bet 169 | {createdBetsCount > 1 ? 's were' : ' was'} placed! 170 |

    171 | ) 172 | } 173 | if (!slipState?.options) return null 174 | return ( 175 | <> 176 |
      177 | {slipState?.options.map((option: BetSlipOption) => ( 178 |
    1. 179 |
      180 | 181 |
      182 |
    2. 183 | ))} 184 |
    185 |
    186 |
      187 | {slipState?.options.map(renderOption)} 188 | {optionIds.length > 1 && 189 | renderOption({ 190 | event: null, 191 | stake: 0, 192 | id: 'long', 193 | name: getLongBetName(optionIds.length), 194 | odds: slipState.options.reduce((acc, o) => acc * o.odds, 1), 195 | marketName: '', 196 | })} 197 |
    198 | {optionIds.length > 0 ? ( 199 |
    200 | 201 |
    202 | ) : ( 203 |
    204 | Click on the odds to add one or more bets to your slip! 205 |
    206 | )} 207 |
    208 | 209 | ) 210 | } 211 | -------------------------------------------------------------------------------- /src/ui/bet-slip/remove-slip-option-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { MarketOption } from '@/gql/types.generated' 4 | // @ts-ignore 5 | import { useFormStatus } from 'react-dom' 6 | import styles from '@/ui/bet-slip/bet-slip.module.css' 7 | import { useContext } from 'react' 8 | import { SlipContext } from '@/lib/slip-context' 9 | 10 | const initialSlipFormState = { 11 | message: null, 12 | } 13 | 14 | type Props = { 15 | option: MarketOption 16 | } 17 | 18 | function DeleteButton() { 19 | const { pending } = useFormStatus() 20 | return ( 21 | 24 | ) 25 | } 26 | 27 | export function RemoveSlipOptionForm({ option }: Props) { 28 | const slipSate = useContext(SlipContext) 29 | if (!slipSate) return null 30 | const { removeOption } = slipSate 31 | 32 | return ( 33 |
    { 35 | removeOption(option.id) 36 | }} 37 | > 38 | 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/ui/card/card-actions.module.css: -------------------------------------------------------------------------------- 1 | .actions { 2 | padding: var(--spacing-medium); 3 | display: flex; 4 | justify-content: flex-end; 5 | border-top: 1px solid var(--salmon); 6 | } 7 | -------------------------------------------------------------------------------- /src/ui/card/card-actions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './CardActions.module.css' 3 | import classnames from 'classnames' 4 | 5 | export type CardActionsProps = { 6 | children: React.ReactNode 7 | className?: string 8 | } 9 | 10 | export function CardActions({ children, className }: CardActionsProps) { 11 | return
    {children}
    12 | } 13 | -------------------------------------------------------------------------------- /src/ui/card/card-content.module.css: -------------------------------------------------------------------------------- 1 | .content { 2 | padding: var(--spacing-large); 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/card/card-content.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './card-content.module.css' 3 | import classnames from 'classnames' 4 | 5 | export type CardContentProps = { 6 | children: React.ReactNode 7 | className?: string 8 | } 9 | 10 | export function CardContent({ children, className }: CardContentProps) { 11 | return
    {children}
    12 | } 13 | -------------------------------------------------------------------------------- /src/ui/card/card-header.module.css: -------------------------------------------------------------------------------- 1 | .cardHeader { 2 | display: flex; 3 | align-items: center; 4 | padding: var(--spacing-x-large); 5 | background-color: var(--egyptian-blue); 6 | margin: var(--spacing-large-minus); 7 | color: var(--white); 8 | border-radius: var(--radius-large) var(--radius-large) 0 0; 9 | } 10 | 11 | .avatar { 12 | margin-right: var(--spacing-large); 13 | /* Adjust size as needed, or make it flexible based on content */ 14 | } 15 | 16 | .headerContent { 17 | flex: 1; 18 | } 19 | 20 | .title { 21 | font-size: 1.25rem; 22 | font-weight: bold; 23 | margin: var(--spacing-small) var(--spacing-large); 24 | } 25 | 26 | .subheader { 27 | font-size: 0.875rem; 28 | color: var(--dark-grey); 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/card/card-header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './card-header.module.css' 3 | import classnames from 'classnames' 4 | 5 | export type CardHeaderProps = { 6 | avatar?: React.ReactNode // Optional avatar or icon 7 | title: React.ReactNode 8 | subheader?: React.ReactNode 9 | className?: string 10 | children?: React.ReactNode 11 | } 12 | 13 | function CardHeader({ avatar, title, subheader, className, children }: CardHeaderProps) { 14 | return ( 15 |
    16 | {avatar &&
    {avatar}
    } 17 |
    18 |
    {title}
    19 | {subheader &&
    {subheader}
    } 20 |
    21 | {children} 22 |
    23 | ) 24 | } 25 | 26 | export default CardHeader 27 | -------------------------------------------------------------------------------- /src/ui/card/card-media.module.css: -------------------------------------------------------------------------------- 1 | .media { 2 | height: 240px; /* Adjust based on your preference */ 3 | width: 100%; /* This makes the image container responsive */ 4 | position: relative; 5 | overflow: hidden; 6 | } 7 | 8 | .overlay { 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | right: 0; 13 | bottom: 0; 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/card/card-media.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Image from 'next/image' 3 | import styles from './CardMedia.module.css' 4 | 5 | export type CardMediaProps = { 6 | image: string 7 | title?: string 8 | layout?: 'fill' | 'responsive' // Optional: Support different layouts 9 | // For 'responsive' layout, width and height must be provided 10 | width?: number 11 | height?: number 12 | children?: React.ReactNode 13 | } 14 | 15 | export function CardMedia({ 16 | image, 17 | title, 18 | layout = 'fill', // Default to 'fill' to cover the div 19 | width, 20 | height, 21 | children, 22 | }: CardMediaProps) { 23 | return ( 24 |
    32 | {title 40 | {children &&
    {children}
    } 41 |
    42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/card/card.module.css: -------------------------------------------------------------------------------- 1 | 2 | .card { 3 | background-color: var(--light-blue); 4 | border-radius: var(--radius-large); 5 | box-shadow: var(--shadow-level-3); 6 | overflow: hidden; 7 | padding: var(--spacing-medium); 8 | } 9 | -------------------------------------------------------------------------------- /src/ui/card/card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './card.module.css' 3 | import classnames from 'classnames' 4 | 5 | export type CardProps = { 6 | children: React.ReactNode 7 | className?: string 8 | } 9 | 10 | export function Card({ children, className }: CardProps) { 11 | return
    {children}
    12 | } 13 | -------------------------------------------------------------------------------- /src/ui/date-util.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | 3 | export function formatTime(date: Date) { 4 | return DateTime.fromJSDate(date).toLocaleString({ 5 | day: '2-digit', 6 | month: '2-digit', 7 | hour: '2-digit', 8 | minute: '2-digit', 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/event-list/add-slip-option-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Market, MarketOption } from '@/gql/types.generated' 4 | // @ts-ignore 5 | import styles from './event-list.module.css' 6 | import { useUser } from '@auth0/nextjs-auth0/client' 7 | import { EventFragment } from '@/gql/documents.generated' 8 | import { BetSlipOption } from '@/ui/bet-slip/bet-slip' 9 | import { useContext } from 'react' 10 | import { SlipContext } from '@/lib/slip-context' 11 | 12 | const initialSlipFormState = { 13 | message: null, 14 | } 15 | 16 | type Props = { 17 | option: MarketOption 18 | event: EventFragment 19 | market: Market 20 | } 21 | 22 | export function AddSlipOptionForm({ option, event, market }: Props) { 23 | const { user } = useUser() 24 | const slipSate = useContext(SlipContext) 25 | 26 | function handleClick(e: { preventDefault: () => void; stopPropagation: () => void }) { 27 | if (!user) { 28 | e.preventDefault() 29 | e.stopPropagation() 30 | console.log('redirecting to login') 31 | return (window.location.href = '/api/auth/login') 32 | } 33 | } 34 | if (!slipSate) return null 35 | return ( 36 |
    { 38 | const betSlipOption: BetSlipOption = { ...option, event, marketName: market.name } 39 | slipSate?.addOption(betSlipOption) 40 | }} 41 | > 42 | 49 |
    50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/ui/event-list/elapsed-time.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { formatTime } from '@/ui/date-util' 3 | import { useEffect, useState } from 'react' 4 | 5 | function elapsedTime(startTime: string) { 6 | const start = new Date(`${startTime}Z`) 7 | const now = new Date() 8 | return now.getTime() - start.getTime() 9 | } 10 | 11 | function formatStartTime(startTime: string) { 12 | const start = new Date(`${startTime}Z`) 13 | return formatTime(start) 14 | } 15 | 16 | export function ElapsedTime({ live, startTime }: { live: boolean; startTime: string }) { 17 | const [elapsed, setElapsed] = useState(elapsedTime(startTime)) 18 | 19 | function formatDuration(ms: number): string { 20 | const padWithZero = (n: number): string => (n < 10 ? '0' + n : n.toString()) 21 | 22 | const seconds = Math.floor(ms / 1000) 23 | const minutes = Math.floor(seconds / 60) 24 | const hours = Math.floor(minutes / 60) 25 | 26 | const remainingSeconds = seconds - minutes * 60 27 | const remainingMinutes = minutes - hours * 60 28 | 29 | return ( 30 | (hours > 0 ? padWithZero(hours) + ':' : '') + 31 | (minutes > 0 || hours > 0 ? padWithZero(remainingMinutes) + ':' : '') + 32 | padWithZero(remainingSeconds) 33 | ) 34 | } 35 | 36 | useEffect(() => { 37 | const interval = setInterval(() => { 38 | setElapsed(elapsedTime(startTime)) 39 | }, 1000) 40 | 41 | return () => clearInterval(interval) 42 | }, [startTime]) 43 | 44 | return
    {live ? formatDuration(elapsed) : formatStartTime(startTime)}
    45 | } 46 | -------------------------------------------------------------------------------- /src/ui/event-list/event-list.module.css: -------------------------------------------------------------------------------- 1 | .noEventsNote { 2 | text-align: center; 3 | } 4 | 5 | .noEventsNote > a { 6 | color: #be8ec4; 7 | font-weight: bold; 8 | } 9 | 10 | .content { 11 | margin-bottom: 1em; 12 | } 13 | 14 | 15 | .events { 16 | display: flex; 17 | flex-direction: row; 18 | width: 100%; 19 | flex-wrap: wrap; 20 | margin-top: 1em; 21 | gap: var(--spacing-medium); 22 | margin-bottom: 1em; 23 | 24 | justify-content: center; 25 | @media screen and (min-width: 80em) { 26 | justify-content: flex-start; 27 | } 28 | } 29 | 30 | @keyframes flash { 31 | from { 32 | background-color: hotpink; 33 | } 34 | to { 35 | background-color: initial; 36 | } 37 | } 38 | 39 | .eventWrapper { 40 | display: flex; 41 | flex-direction: column; 42 | border: 1px solid #ccc; 43 | border-radius: var(--radius-large); 44 | color: black; 45 | width: 430px; 46 | height: 235px; 47 | background-color: var(--off-white); 48 | } 49 | 50 | .eventWrapper.flash { 51 | animation: flash 2s; 52 | } 53 | 54 | .eventHeader { 55 | display: flex; 56 | flex-direction: row; 57 | justify-content: center; 58 | /*font-weight: 600;*/ 59 | color: var(--black); 60 | background-color: var(--peach-yellow); 61 | margin-bottom: 1em; 62 | padding: .5em 1em; 63 | border-top-left-radius: var(--radius-large); 64 | border-top-right-radius: var(--radius-large); 65 | } 66 | 67 | .headerItem { 68 | display: flex; 69 | flex-direction: row; 70 | gap: 0.5em; 71 | text-align: center; 72 | max-width: 200px; 73 | } 74 | 75 | .headerSubItem { 76 | padding: 0.3em; 77 | } 78 | 79 | .headerSubItem2 { 80 | padding: 0.3em; 81 | background-color: var(--off-white); 82 | } 83 | 84 | .oddsWrapper { 85 | display: flex; 86 | flex-direction: row; 87 | justify-content: space-between; 88 | align-items: flex-end; 89 | padding: 0 2em; 90 | } 91 | 92 | .market { 93 | display: flex; 94 | flex-direction: column; 95 | width: 100%; 96 | text-align: center; 97 | } 98 | 99 | .eventName { 100 | display: flex; 101 | align-items: center; 102 | justify-content: center; 103 | height: 2em; 104 | font-size: 1.2rem; 105 | font-weight: 400; 106 | color: var(--black); 107 | text-align: center; 108 | min-height: 1.5rem; 109 | max-width: 400px; 110 | word-wrap: break-word; 111 | } 112 | 113 | .oddsBox { 114 | display: flex; 115 | flex-direction: row; 116 | justify-content: space-between; 117 | border-top: 2px solid var(--pink-lavender); 118 | margin-top: 1em; 119 | padding-top: 1em; 120 | } 121 | 122 | .oddsValue { 123 | margin-top: 8px; 124 | font-weight: bold; 125 | border-radius: 5px; 126 | } 127 | 128 | .optionName { 129 | font-size: 1rem; 130 | color: #222; 131 | text-align: center; 132 | min-height: 1.5rem; 133 | 134 | .point { 135 | font-weight: 600; 136 | font-size: 0.8rem; 137 | } 138 | } 139 | 140 | .oddsHistory { 141 | font-size: 10px; 142 | color: #666; 143 | height: 10px; 144 | padding-bottom: 2px; 145 | } 146 | 147 | .addSlipOptionButton { 148 | cursor: pointer; 149 | padding: 0.5em 2em 0.5em 2em; 150 | background-color: var(--pink-lavender); 151 | color: black; 152 | border: none; 153 | 154 | @media screen and (max-width: 60em) { 155 | padding: 0.5em 1.4em; 156 | } 157 | } 158 | 159 | .addSlipOptionButton:hover { 160 | background-color: rgb(255, 105, 180, 0.7); 161 | box-shadow: -2px 2px 10px rgba(100, 100, 100, 0.3); 162 | } 163 | -------------------------------------------------------------------------------- /src/ui/event-list/event-list.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { EventFragment, MarketFragment } from '@/gql/documents.generated' 4 | import { MarketOption } from '@/gql/types.generated' 5 | import { CSSTransition } from 'react-transition-group' 6 | import styles from './event-list.module.css' 7 | import { ElapsedTime } from '@/ui/event-list/elapsed-time' 8 | import { renderScore } from '@/ui/event-util' 9 | import { Market } from './market' 10 | import React from 'react' 11 | 12 | export type MarketOptionWithHistory = MarketOption & { history: string } 13 | 14 | const eventCompare = (live: boolean) => (a: EventFragment, b: EventFragment) => { 15 | const startA = new Date(a.startTime) 16 | const startB = new Date(b.startTime) 17 | if (startA < startB) return live ? 1 : -1 18 | if (startA > startB) return live ? -1 : 1 19 | return 0 20 | } 21 | 22 | export function EventList({ 23 | events, 24 | marketName, 25 | live = false, 26 | updatedEvents = [], 27 | }: { 28 | events: EventFragment[] 29 | marketName: string 30 | live?: boolean 31 | updatedEvents?: string[] 32 | }) { 33 | if (events.length === 0) 34 | return ( 35 |

    36 | {events.length === 0 && 37 | (live ? 'No live events at the moment.' : 'No upcoming events at the moment.')} 38 |

    39 | ) 40 | 41 | return ( 42 |
    43 |

    {live ? 'Live now' : 'Upcoming'}

    44 |
    45 | {events.sort(eventCompare(live)).map((event: EventFragment) => { 46 | if (!event) return null 47 | const selectedMarket = event?.markets?.find( 48 | (market) => 49 | market?.name === marketName && 50 | (market.name === 'spreads' 51 | ? market.options?.find((option) => option?.point !== 0) 52 | : true) 53 | ) 54 | 55 | if (!selectedMarket?.options) { 56 | console.log(`Event '${event.name}' market '${marketName}' has no options`) 57 | return null 58 | } 59 | 60 | return ( 61 | 62 |
    67 |
    68 |
    69 |
    {event.sport.title}
    70 | {/*
    {event.name}
    */} 71 |
    72 |
    73 |
    74 | 75 |
    76 | {live && !event.sport.key.startsWith('tennis') && ( 77 |
    {renderScore(event)}
    78 | )} 79 |
    80 |
    81 |
    82 | 83 |
    84 |
    85 |
    86 | ) 87 | })} 88 |
    89 |
    90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/ui/event-list/live-event-list.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | EventFragment, 5 | EventStatusUpdatesDocument, 6 | MarketOptionUpdatesDocument, 7 | ScoreUpdatesDocument, 8 | } from '@/gql/documents.generated' 9 | import { useEffect, useState } from 'react' 10 | import { useSubscription } from '@apollo/client' 11 | import { MarketOption } from '@/gql/types.generated' 12 | import { EventList } from '@/ui/event-list/event-list' 13 | import { 14 | getUpdatedEventsForNewEventStatuses, 15 | getUpdatedEventsForNewMarketOptions, 16 | getUpdatedEventsForNewScores, 17 | } from '@/ui/event-util' 18 | 19 | export function LiveEventList({ 20 | events, 21 | marketName, 22 | }: { 23 | events: EventFragment[] 24 | marketName: string 25 | }) { 26 | const [liveEvents, setLiveEvents] = useState(events) 27 | const [updatedEvents, setUpdatedEvents] = useState([]) 28 | 29 | const { data: scoreUpdatesData, error: scoreError } = useSubscription(ScoreUpdatesDocument) 30 | const { data: optionsUpdateData, error: optionsError } = useSubscription( 31 | MarketOptionUpdatesDocument 32 | ) 33 | const { data: statusUpdateData, error: statusError } = useSubscription(EventStatusUpdatesDocument) 34 | 35 | const error = optionsError || scoreError || statusError 36 | useEffect(() => { 37 | if (error) { 38 | console.error(`Subscription error: ${error.message}`) 39 | } 40 | }, [error]) 41 | 42 | const setClearUpdatedEvents = () => { 43 | // Remove the updated event IDs from the state after the animation duration 44 | setTimeout(() => { 45 | setUpdatedEvents([]) 46 | }, 2000) // adjust this value according to the duration of your animation 47 | } 48 | 49 | useEffect(() => { 50 | if (optionsUpdateData?.liveMarketOptionsUpdated) { 51 | setLiveEvents((current) => { 52 | const result = getUpdatedEventsForNewMarketOptions( 53 | current, 54 | optionsUpdateData.liveMarketOptionsUpdated as MarketOption[] 55 | ) 56 | if (result.updatedEvents.length > 0) { 57 | setUpdatedEvents(result.updatedEvents) 58 | } 59 | return result.newLiveEvents 60 | }) 61 | setClearUpdatedEvents() 62 | } 63 | }, [optionsUpdateData]) 64 | 65 | useEffect(() => { 66 | if (scoreUpdatesData?.eventScoresUpdated) { 67 | console.info('scoreUpdatesData', scoreUpdatesData) 68 | setLiveEvents((current) => 69 | getUpdatedEventsForNewScores( 70 | current, 71 | scoreUpdatesData?.eventScoresUpdated as EventFragment[] 72 | ) 73 | ) 74 | setUpdatedEvents(scoreUpdatesData?.eventScoresUpdated.map((event) => event?.id ?? '')) 75 | setClearUpdatedEvents() 76 | } 77 | }, [scoreUpdatesData]) 78 | 79 | useEffect(() => { 80 | if (statusUpdateData?.eventStatusUpdated) { 81 | console.info('statusUpdateData', statusUpdateData) 82 | setLiveEvents((current) => 83 | getUpdatedEventsForNewEventStatuses( 84 | current, 85 | statusUpdateData?.eventStatusUpdated as EventFragment[] 86 | ) 87 | ) 88 | } 89 | }, [statusUpdateData]) 90 | return ( 91 | 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /src/ui/event-list/market.tsx: -------------------------------------------------------------------------------- 1 | import { EventFragment, MarketFragment } from '@/gql/documents.generated' 2 | import styles from '@/ui/event-list/event-list.module.css' 3 | import { AddSlipOptionForm } from '@/ui/event-list/add-slip-option-form' 4 | import { MarketOption } from '@/gql/types.generated' 5 | import { MarketOptionWithHistory } from '@/ui/event-list/event-list' 6 | import { getOptionPointLabel } from '@/ui/event-util' 7 | 8 | export function Market({ 9 | event, 10 | market, 11 | live, 12 | }: { 13 | event: EventFragment 14 | market: MarketFragment 15 | live: boolean 16 | }) { 17 | const options = market?.options 18 | if (!options) return null 19 | 20 | const sortedOptions = options.sort((a, b) => { 21 | const order = ['home', 'draw', 'away'] // define your order 22 | const getTeamType = (option: MarketOption) => { 23 | if (option.name === event.homeTeamName) return 'home' 24 | if (option.name === 'Draw') return 'draw' 25 | if (option.name === event.awayTeamName) return 'away' 26 | return 'other' 27 | } 28 | return ( 29 | order.indexOf(getTeamType(a as MarketOption)) - order.indexOf(getTeamType(b as MarketOption)) 30 | ) 31 | }) 32 | 33 | function renderOptions(options: JSX.Element[]) { 34 | return ( 35 |
    36 |
    {event.name}
    37 |
    {options}
    38 |
    39 | ) 40 | } 41 | 42 | if (market.name === 'h2h' || market.name === 'h2h_lay' || market.name === 'spreads') { 43 | const h2hOptions = ['1', 'x', '2'] 44 | const options = sortedOptions.map((option, i: number) => ( 45 |
    46 | {market.name === 'spreads' ? getOptionPointLabel(option, market.name) : h2hOptions[i]} 47 | 48 | {live && ( 49 |
    50 | {(option as MarketOptionWithHistory).history ?? ''} 51 |
    52 | )} 53 |
    54 | )) 55 | return renderOptions(options) 56 | } 57 | if (market.name === 'totals') { 58 | const options = sortedOptions.map((option) => ( 59 |
    60 |
    61 | {option?.name} {option?.point} 62 |
    63 | 64 | {live && ( 65 |
    66 | {(option as MarketOptionWithHistory).history ?? ''} 67 |
    68 | )} 69 |
    70 | )) 71 | return renderOptions(options) 72 | } 73 | 74 | return
    75 | } 76 | -------------------------------------------------------------------------------- /src/ui/event-util.ts: -------------------------------------------------------------------------------- 1 | import { EventFragment } from '@/gql/documents.generated' 2 | import { BetOption, MarketOption } from '@/gql/types.generated' 3 | import * as R from 'ramda' 4 | import { BetSlipOption } from '@/ui/bet-slip/bet-slip' 5 | 6 | export const getUpdatedEventsForNewMarketOptions = ( 7 | currentEvents: EventFragment[], 8 | updatedMarketOptions: MarketOption[] 9 | ): { 10 | newLiveEvents: EventFragment[] 11 | updatedEvents: string[] 12 | } => { 13 | let updatedEvents: string[] = [] 14 | const newLiveEvents = currentEvents.map((event) => { 15 | const updatedMarkets = event?.markets?.map((market) => { 16 | if (market?.options) { 17 | const updatedOptions = market.options.map((option) => { 18 | const updatedOption = updatedMarketOptions.find( 19 | (updatedOption) => updatedOption.id === option?.id 20 | ) 21 | if (updatedOption && updatedOption.odds !== option?.odds) { 22 | updatedEvents.push(event.id) 23 | const history = 24 | option?.odds !== updatedOption.odds ? `${option?.odds} -> ${updatedOption.odds}` : '' 25 | return { ...option, ...updatedOption, history } 26 | } 27 | return option 28 | }) 29 | return { ...market, options: updatedOptions } 30 | } 31 | return market 32 | }) 33 | return { ...event, markets: updatedMarkets } 34 | }) 35 | return { newLiveEvents, updatedEvents } 36 | } 37 | 38 | export const getUpdatedEventsForNewScores = ( 39 | currentEvents: EventFragment[], 40 | updatedEvents: EventFragment[] 41 | ) => { 42 | return currentEvents.map((currentEvent) => { 43 | const updatedEvent = updatedEvents.find((updatedEvent) => updatedEvent.id === currentEvent.id) 44 | if (currentEvent.id === updatedEvent?.id) { 45 | console.log('new score', updatedEvent) 46 | return { 47 | ...currentEvent, 48 | scoreUpdates: R.uniqBy(R.prop('id'), [ 49 | ...(currentEvent.scoreUpdates ?? []), 50 | ...(updatedEvent.scoreUpdates ?? []), 51 | ]), 52 | } 53 | } 54 | return currentEvent 55 | }) 56 | } 57 | 58 | export const getUpdatedEventsForNewEventStatuses = ( 59 | currentEvents: EventFragment[], 60 | updatedEvents: EventFragment[] 61 | ) => { 62 | const newLiveEvents = updatedEvents.filter( 63 | (updatedEvent) => 64 | updatedEvent.isLive && 65 | !updatedEvent.completed && 66 | !currentEvents.find((event) => event.id === updatedEvent.id) 67 | ) 68 | const completedLiveEvents = updatedEvents.filter((updatedEvent) => updatedEvent.completed) 69 | 70 | console.log('number of new live events', newLiveEvents.length) 71 | console.log('number of completed events', completedLiveEvents.length) 72 | 73 | return [...currentEvents, ...newLiveEvents].filter( 74 | (event) => !completedLiveEvents.find((completedEvent) => completedEvent.id === event.id) 75 | ) 76 | } 77 | 78 | export function renderScore(event: EventFragment): string { 79 | if (!event?.scoreUpdates?.length) { 80 | return '0 - 0' 81 | } 82 | const getTeamScore = ( 83 | teamName: string // get maximum from the array 84 | ) => 85 | Math.max( 86 | ...(event?.scoreUpdates 87 | ?.filter((s) => s && s.name === teamName) 88 | .map((s) => parseInt(s?.score ?? '0')) ?? [0]) 89 | ) 90 | 91 | const homeScore = getTeamScore(event?.homeTeamName) 92 | const awayScore = getTeamScore(event?.awayTeamName) 93 | return `${homeScore} - ${awayScore}` 94 | } 95 | 96 | export function getOptionPointLabel( 97 | option: { 98 | point?: number | null 99 | description?: string | null 100 | } | null, 101 | marketName: string 102 | ): string { 103 | if (!option) return '' 104 | if (!option.point) return '' 105 | if (marketName === 'totals') return `${option.point}` 106 | return option.point > 0 ? `+${option.point}` : `${option.point}` 107 | } 108 | 109 | export function getSpreadOptionLabel(event: EventFragment | null, homeTeam: boolean) { 110 | if (!event) return '' 111 | const spreadMarket = event.markets?.find((m) => m?.name === 'spreads') 112 | if (!spreadMarket) return '' 113 | const teamOption = spreadMarket.options?.find( 114 | (o) => o?.name === (homeTeam ? event.homeTeamName : event.awayTeamName) 115 | ) 116 | if (!teamOption) return '' 117 | return getOptionPointLabel(teamOption, spreadMarket.name) 118 | } 119 | -------------------------------------------------------------------------------- /src/ui/globals.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | width: 100%; 3 | height: 40px; 4 | border: none; 5 | border-radius: 5px; 6 | font-size: 1.2rem; 7 | font-weight: 600; 8 | margin-top: 10px; 9 | cursor: pointer; 10 | } 11 | 12 | .primary { 13 | background-color: var(--salmon); 14 | color: white; 15 | } -------------------------------------------------------------------------------- /src/ui/page-nav.module.css: -------------------------------------------------------------------------------- 1 | .marketNav { 2 | position: fixed; 3 | top: 34px; 4 | background-color: var(--off-white-transparent); ; 5 | margin-left: -190px; 6 | width: 100vw; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | padding: 0.5rem 0; 11 | /*border-bottom: 5px solid var(--light-blue);*/ 12 | gap: 1em; 13 | font-size: 1.2rem; 14 | font-weight: 400; 15 | color: var(--egyptian-blue); 16 | /*// sticky at top of page*/ 17 | 18 | @media screen and (max-width: 60em) { 19 | margin-left: -30px; 20 | width: 100%; 21 | } 22 | } 23 | 24 | .marketNav a { 25 | color: var(--egyptian-blue); 26 | } 27 | 28 | .active { 29 | font-weight: 600; 30 | font-size: 1.1em; 31 | background-color: var(--off-white); 32 | color: var(--black); 33 | } 34 | -------------------------------------------------------------------------------- /src/ui/page-nav.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import styles from './page-nav.module.css' 4 | import { usePathname } from 'next/navigation' 5 | import Link from 'next/link' 6 | import classnames from 'classnames' 7 | 8 | type SelectionResolver = (slug: string, path: string) => boolean 9 | 10 | export function NavLink({ 11 | label, 12 | slug, 13 | className, 14 | activeClassName, 15 | selectionResolver, 16 | }: { 17 | label: string 18 | slug: string 19 | className?: string 20 | activeClassName?: string 21 | selectionResolver?: SelectionResolver 22 | }) { 23 | const path = usePathname() 24 | const isActive = selectionResolver?.(slug, path) 25 | 26 | return ( 27 |
  • 28 | 34 | {label} 35 | 36 |
  • 37 | ) 38 | } 39 | 40 | export function MarketNav({ prefix }: { prefix: string }) { 41 | const selectionResolver: SelectionResolver = (slug, path) => { 42 | const pathHasMarket = path.split('/').length >= 3 43 | return path.includes(slug) || (slug.includes('h2h') && !pathHasMarket) 44 | } 45 | 46 | return ( 47 | 54 | ) 55 | } 56 | 57 | export function PageNav() { 58 | const selectionResolver: SelectionResolver = (slug, path) => { 59 | console.log('slug: ', slug.replace('/', '')) 60 | console.log('path: ', path.replace('/', '')) 61 | 62 | return slug.replace('/', '') == path.replace('/', '') 63 | } 64 | 65 | return ( 66 |
      67 |
    • 68 | 75 |
    • 76 |
    • 77 | 84 |
    • 85 |
    86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /src/ui/side-menu/side-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Sport } from '@/gql/types.generated' 2 | import Link from 'next/link' 3 | import { usePathname } from 'next/navigation' 4 | 5 | export type Props = { 6 | sports: Sport[] 7 | } 8 | 9 | export function SideMenu({ sports }: Props) { 10 | const groups: Map = sports.reduce((acc, sport) => { 11 | const sports = acc.get(sport.group) || [] 12 | sports.push(sport) 13 | acc.set(sport.group, sports) 14 | return acc 15 | }, new Map()) 16 | 17 | return ( 18 | <> 19 | 20 | 21 | 22 | 23 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/ui/sport-list/sport-list.module.css: -------------------------------------------------------------------------------- 1 | .groupTitle { 2 | color: var(--egyptian-blue); 3 | } 4 | 5 | .container { 6 | display: flex; 7 | flex-direction: column; 8 | gap: var(--spacing-large); 9 | margin-top: 80px 10 | } 11 | 12 | .closedMarketContent { 13 | color: var(--egyptian-blue) 14 | } -------------------------------------------------------------------------------- /src/ui/sport-list/sport-list.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { EventFragment, SportWithEventsFragment } from '@/gql/documents.generated' 4 | import { LiveEventList } from '@/ui/event-list/live-event-list' 5 | import { EventList } from '@/ui/event-list/event-list' 6 | import React from 'react' 7 | import { Card } from '@/ui/card/card' 8 | import CardHeader from '@/ui/card/card-header' 9 | import { CardContent } from '@/ui/card/card-content' 10 | import styles from './sport-list.module.css' 11 | import { SlipProvider } from '@/lib/slip-context' 12 | import BetSlip from '@/ui/bet-slip/bet-slip' 13 | 14 | const eventHasOptionsInMarket = 15 | (market: string) => 16 | (event: EventFragment): boolean => { 17 | const selectedMarket = event?.markets?.find( 18 | (m) => 19 | m?.name === market && (m.name === 'spreads' ? m.options?.find((o) => o?.point !== 0) : true) 20 | ) 21 | return Number(selectedMarket?.options?.length) > 0 22 | } 23 | 24 | export type SportListProps = { 25 | group: string 26 | sports: SportWithEventsFragment[] 27 | market: string 28 | } 29 | function SportListPlain({ group, sports, market }: SportListProps) { 30 | const sportsWithEvents = sports.filter((sport) => sport?.events?.length) 31 | const sportsWithoutEvents = sports.filter((sport) => !sport?.events?.length) 32 | 33 | return ( 34 |
    35 |

    36 | {(group ?? 'all') === 'all' ? 'All Sports' : decodeURIComponent(group)} 37 |

    38 | {[...sportsWithEvents, ...sportsWithoutEvents].map((sport) => { 39 | const liveEvents = ( 40 | sport.events?.filter((event) => event?.isLive) as EventFragment[] 41 | ).filter(eventHasOptionsInMarket(market)) 42 | const upcomingEvents = ( 43 | sport.events?.filter((event) => !event?.isLive) as EventFragment[] 44 | ).filter(eventHasOptionsInMarket(market)) 45 | 46 | if (!liveEvents.length && !upcomingEvents.length) 47 | return ( 48 | 49 | 50 | 51 |

    Market closed.

    52 |
    53 |
    54 | ) 55 | 56 | return ( 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | ) 65 | })} 66 |
    67 | ) 68 | } 69 | 70 | export function SportList({ group, sports, market }: SportListProps) { 71 | return ( 72 | 73 | 74 | 75 | 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /src/ui/top-bar/top-bar.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: fixed; 3 | top: 0; 4 | z-index: 100; 5 | width: 100%; 6 | background-color: var(--pink-lavender); 7 | color: var(--black); 8 | -webkit-font-smoothing: antialiased; 9 | height: 2.1em; 10 | overflow: visible; 11 | -webkit-transition: height 0.5s; 12 | -moz-transition: height 0.5s; 13 | -ms-transition: height 0.5s; 14 | transition: height 0.5s; 15 | } 16 | 17 | .profileActions { 18 | display: flex; 19 | flex-direction: row; 20 | gap: 1em; 21 | align-items: center; 22 | } 23 | 24 | .container a, .container li { 25 | color: black; 26 | } 27 | 28 | .label { 29 | border: none; 30 | font-weight: 300; 31 | color: black; 32 | display: inline; 33 | } 34 | 35 | .balance { 36 | display: inline; 37 | color: var(--egyptian-blue); 38 | margin-right: 0.5em; 39 | } 40 | 41 | 42 | .open { 43 | height: 14em; 44 | } 45 | 46 | .profileActions { 47 | text-align: right; 48 | } 49 | 50 | .accountInfo { 51 | padding-right: 1em !important; 52 | } 53 | 54 | .toggle { 55 | width: 34px; 56 | height: 34px; 57 | position: absolute; 58 | top: 0; 59 | right: 0; 60 | display: none; 61 | } 62 | 63 | .toggle .bar { 64 | background-color: #777; 65 | display: block; 66 | width: 20px; 67 | height: 2px; 68 | border-radius: 100px; 69 | position: absolute; 70 | top: 18px; 71 | right: 7px; 72 | -webkit-transition: all 0.5s; 73 | -moz-transition: all 0.5s; 74 | -ms-transition: all 0.5s; 75 | transition: all 0.5s; 76 | } 77 | 78 | .toggle .bar:first-child { 79 | -webkit-transform: translateY(-6px); 80 | -moz-transform: translateY(-6px); 81 | -ms-transform: translateY(-6px); 82 | transform: translateY(-6px); 83 | } 84 | 85 | .x .bar { 86 | -webkit-transform: rotate(45deg); 87 | -moz-transform: rotate(45deg); 88 | -ms-transform: rotate(45deg); 89 | transform: rotate(45deg); 90 | } 91 | 92 | .x .bar:first-child { 93 | -webkit-transform: rotate(-45deg); 94 | -moz-transform: rotate(-45deg); 95 | -ms-transform: rotate(-45deg); 96 | transform: rotate(-45deg); 97 | } 98 | 99 | @media (max-width: 47.999em) { 100 | .profileActions { 101 | text-align: left; 102 | } 103 | 104 | .toggle { 105 | display: block; 106 | } 107 | 108 | .container { 109 | overflow: hidden; 110 | } 111 | } -------------------------------------------------------------------------------- /src/ui/top-bar/top-bar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | import { formatTime } from '@/ui/date-util' 5 | import { useUser } from '@auth0/nextjs-auth0/client' 6 | import { useCallback, useEffect, useRef, useState } from 'react' 7 | import styles from './top-bar.module.css' 8 | import { User } from '@/gql/types.generated' 9 | import { PageNav } from '@/ui/page-nav' 10 | 11 | export type Props = { 12 | bettingUser: User | null 13 | } 14 | 15 | export default function TopBar({ bettingUser }: Props) { 16 | const { user, isLoading } = useUser() 17 | const [userMenuVisible, setUserMenuVisible] = useState(false) 18 | const [toggleHorizontalTimeout, setToggleHorizontalTimeout] = useState( 19 | null 20 | ) 21 | const menuRef = useRef(null) // Specify the type for the ref 22 | const toggleRef = useRef(null) // Specify the type for the ref 23 | 24 | const handleUserMenuClick = () => { 25 | setUserMenuVisible(!userMenuVisible) 26 | } 27 | 28 | const toggleMenu = useCallback(() => { 29 | const toggleHorizontal = () => { 30 | if (!menuRef.current) return 31 | menuRef.current.classList.remove('closing') 32 | Array.from(menuRef.current.querySelectorAll('.custom-can-transform')).forEach((el) => { 33 | el.classList.toggle('pure-menu-horizontal') 34 | }) 35 | } 36 | console.log('toggleMenu()') 37 | if (!menuRef.current || !toggleRef.current) return 38 | if (menuRef.current.classList.contains(styles.open)) { 39 | menuRef.current.classList.add('closing') 40 | setToggleHorizontalTimeout(setTimeout(toggleHorizontal, 500)) 41 | } else { 42 | if (menuRef.current.classList.contains('closing')) { 43 | if (toggleHorizontalTimeout) clearTimeout(toggleHorizontalTimeout) 44 | } else { 45 | toggleHorizontal() 46 | } 47 | } 48 | menuRef.current.classList.toggle(styles.open) 49 | toggleRef.current.classList.toggle(styles.x) 50 | }, [toggleHorizontalTimeout]) 51 | 52 | useEffect(() => { 53 | const closeMenu = () => { 54 | if (menuRef.current && menuRef.current.classList.contains(styles.open)) { 55 | toggleMenu() 56 | } 57 | } 58 | const windowChangeEvent = 'onorientationchange' in window ? 'orientationchange' : 'resize' 59 | window.addEventListener(windowChangeEvent, closeMenu) 60 | return () => { 61 | window.removeEventListener(windowChangeEvent, closeMenu) 62 | } 63 | }, [toggleHorizontalTimeout, toggleMenu]) 64 | 65 | return ( 66 |
    67 | 87 |
    88 |
    89 | 90 |
    91 |
    92 |
    93 |
    94 |
      95 |
    • 96 | {user ? ( 97 | 98 | {user.name} 99 | 100 | ) : ( 101 | 102 | Login 103 | 104 | )} 105 | {user && ( 106 |
        107 |
      • 108 | 109 | Logout 110 | 111 |
      • 112 |
      113 | )} 114 |
    • 115 |
    • 116 |
      €{bettingUser?.wallet?.balance ?? 0}
      117 |
      {formatTime(new Date())}
      118 |
    • 119 |
    120 |
    121 |
    122 |
    123 | //
    124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------