├── .dockerignore ├── .env.development ├── .env.e2e ├── .env.production ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── release.yml └── workflows │ ├── ci.yml │ ├── create-release.yml │ ├── docker.yml │ └── tag.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── config ├── Caddyfile └── entrypoint.sh ├── cypress.config.js ├── cypress ├── e2e │ └── pages │ │ ├── AcceptInvite.spec.cy.js │ │ ├── Billing.spec.cy.js │ │ ├── Demo.spec.cy.js │ │ ├── ForgotPassword.spec.cy.js │ │ ├── Login.spec.cy.js │ │ ├── RecoverAccount.spec.cy.js │ │ ├── Register.spec.cy.js │ │ ├── ResetPassword.spec.cy.js │ │ └── Verify2FA.spec.cy.js ├── fixtures │ └── responses │ │ ├── auth │ │ ├── dev.json │ │ ├── owner.json │ │ └── register.json │ │ ├── billing │ │ ├── free-plan.json │ │ ├── invoice.json │ │ ├── plans.json │ │ └── usage.json │ │ └── game-stats │ │ └── global.json └── support │ ├── commands.js │ └── e2e.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico └── robots.txt ├── renovate.json ├── setup-tests.js ├── src ├── App.tsx ├── Router.tsx ├── __mocks__ │ ├── gameFeedbackCategoryMock.ts │ ├── gameMock.ts │ ├── gameStatMock.ts │ ├── leaderboardMock.ts │ ├── organisationMock.ts │ ├── playerAliasMock.ts │ ├── playerMock.ts │ ├── pricingPlanMock.ts │ └── userMock.ts ├── api │ ├── api.ts │ ├── changePassword.ts │ ├── confirmEmail.ts │ ├── confirmEnable2FA.ts │ ├── confirmPlan.ts │ ├── createAPIKey.ts │ ├── createChannel.ts │ ├── createCheckoutSession.ts │ ├── createDataExport.ts │ ├── createDemo.ts │ ├── createFeedbackCategory.ts │ ├── createGame.ts │ ├── createGroup.ts │ ├── createInvite.ts │ ├── createLeaderboard.ts │ ├── createPortalSession.ts │ ├── createRecoveryCodes.ts │ ├── createStat.ts │ ├── deleteAPIKey.ts │ ├── deleteChannel.ts │ ├── deleteFeedbackCategory.ts │ ├── deleteGroup.ts │ ├── deleteLeaderboard.ts │ ├── deleteStat.ts │ ├── disable2FA.ts │ ├── disableIntegration.ts │ ├── enable2FA.ts │ ├── enableIntegration.ts │ ├── findLeaderboard.ts │ ├── findPlayer.ts │ ├── getInvite.ts │ ├── login.ts │ ├── logout.ts │ ├── makeValidatedGetRequest.ts │ ├── makeValidatedRequest.ts │ ├── recoverAccount.ts │ ├── refreshAccess.ts │ ├── register.ts │ ├── requestNewPassword.ts │ ├── resetPassword.ts │ ├── syncLeaderboards.ts │ ├── syncStats.ts │ ├── toggledPinnedGroup.ts │ ├── updateAPIKey.ts │ ├── updateChannel.ts │ ├── updateFeedbackCategory.ts │ ├── updateGame.ts │ ├── updateGroup.ts │ ├── updateIntegration.ts │ ├── updateLeaderboard.ts │ ├── updateLeaderboardEntry.ts │ ├── updatePlayer.ts │ ├── updateStat.ts │ ├── updateStatValue.ts │ ├── useAPIKeys.ts │ ├── useChannels.ts │ ├── useDataExportEntities.ts │ ├── useDataExports.ts │ ├── useEventBreakdown.ts │ ├── useEvents.ts │ ├── useFeedback.ts │ ├── useFeedbackCategories.ts │ ├── useGameActivities.ts │ ├── useGameSettings.ts │ ├── useGroupPreviewCount.ts │ ├── useGroupRules.ts │ ├── useGroups.ts │ ├── useHeadlines.ts │ ├── useIntegrations.ts │ ├── useLeaderboardEntries.ts │ ├── useLeaderboards.ts │ ├── useOrganisation.ts │ ├── useOrganisationPricingPlan.ts │ ├── usePinnedGroups.ts │ ├── usePlayerAuthActivities.ts │ ├── usePlayerEvents.ts │ ├── usePlayerHeadlines.ts │ ├── usePlayerLeaderboardEntries.ts │ ├── usePlayerSaves.ts │ ├── usePlayerStats.ts │ ├── usePlayers.ts │ ├── usePricingPlanUsage.ts │ ├── usePricingPlans.ts │ ├── useStats.ts │ ├── verify2FA.ts │ └── viewRecoveryCodes.ts ├── assets │ ├── talo-icon.svg │ └── talo-service.svg ├── components │ ├── Account2FA.tsx │ ├── ActivityRenderer.tsx │ ├── AlertBanner.tsx │ ├── Button.tsx │ ├── Checkbox.tsx │ ├── CheckboxButton.tsx │ ├── ConfirmEmailBanner.tsx │ ├── DateInput.tsx │ ├── DevDataStatus.tsx │ ├── DropdownMenu.tsx │ ├── ErrorMessage.tsx │ ├── Footer.tsx │ ├── GameSwitcher.tsx │ ├── GlobalBanners.tsx │ ├── HeadlineStat.tsx │ ├── Identifier.tsx │ ├── IntendedRouteHandler.tsx │ ├── Link.tsx │ ├── LinkButton.tsx │ ├── Loading.tsx │ ├── MobileMenu.tsx │ ├── Modal.tsx │ ├── NavBar.tsx │ ├── Page.tsx │ ├── Pagination.tsx │ ├── PlayerAliases.tsx │ ├── PlayerIdentifier.tsx │ ├── PropsEditor.tsx │ ├── RadioGroup.tsx │ ├── RecoveryCodes.tsx │ ├── SecondaryNav.tsx │ ├── SecondaryTitle.tsx │ ├── Select.tsx │ ├── ServicesLink.tsx │ ├── TaloInfoCard.tsx │ ├── TextInput.tsx │ ├── Tile.tsx │ ├── TimePeriodPicker.tsx │ ├── Title.tsx │ ├── UsageWarningBanner.tsx │ ├── __tests__ │ │ ├── Account2FA.test.tsx │ │ ├── ConfirmEmailBanner.test.tsx │ │ ├── DateInput.test.tsx │ │ ├── DevDataStatus.test.tsx │ │ ├── DropdownMenu.test.tsx │ │ ├── ErrorMessage.test.tsx │ │ ├── GameSwitcher.test.tsx │ │ ├── GlobalBanners.test.tsx │ │ ├── Link.test.tsx │ │ ├── MobileMenu.test.tsx │ │ ├── Modal.test.tsx │ │ ├── NavBar.test.tsx │ │ ├── Pagination.test.tsx │ │ ├── PlayerAliases.test.tsx │ │ ├── PlayerIdentifier.test.tsx │ │ ├── RecoveryCodes.test.tsx │ │ ├── SecondaryNav.test.tsx │ │ ├── ServicesLink.test.tsx │ │ ├── TimePeriodPicker.test.tsx │ │ └── Title.test.tsx │ ├── billing │ │ ├── BillingPortalTile.tsx │ │ ├── BillingUsageTile.tsx │ │ ├── PaymentRequiredBanner.tsx │ │ ├── PricingPlanTile.tsx │ │ ├── RegisterPlanBanner.tsx │ │ └── __tests__ │ │ │ ├── BillingPortalTile.test.tsx │ │ │ ├── BillingUsageTile.test.tsx │ │ │ ├── PaymentRequiredBanner.test.tsx │ │ │ ├── PricingPlanTile.test.tsx │ │ │ └── RegisterPlanBanner.test.tsx │ ├── charts │ │ ├── ChartTick.tsx │ │ ├── ChartTooltip.tsx │ │ └── __tests__ │ │ │ ├── ChartTick.test.tsx │ │ │ └── ChartTooltip.test.tsx │ ├── events │ │ ├── EventsContext.tsx │ │ ├── EventsDisplay.tsx │ │ ├── EventsFilter.tsx │ │ ├── EventsFiltersSection.tsx │ │ └── useEventsTimePeriod.tsx │ ├── saves │ │ ├── SaveContentFitManager.tsx │ │ └── SaveDataNode.tsx │ ├── tables │ │ ├── Table.tsx │ │ ├── TableBody.tsx │ │ ├── TableCell.tsx │ │ ├── TableHeader.tsx │ │ └── cells │ │ │ └── DateCell.tsx │ ├── toast │ │ ├── ToastContext.ts │ │ ├── ToastProvider.test.tsx │ │ └── ToastProvider.tsx │ └── toggles │ │ ├── DevDataToggle.tsx │ │ ├── Toggle.tsx │ │ └── __tests__ │ │ ├── DevDataToggle.test.tsx │ │ └── Toggle.test.tsx ├── constants │ ├── metaProps.ts │ ├── routes.ts │ ├── secondaryNavRoutes.ts │ └── userTypeMap.ts ├── entities │ ├── apiKey.ts │ ├── dataExport.ts │ ├── event.ts │ ├── game.ts │ ├── gameActivity.ts │ ├── gameChannels.ts │ ├── gameFeedback.ts │ ├── gameFeedbackCategory.ts │ ├── gameSave.ts │ ├── gameStat.ts │ ├── headline.ts │ ├── integration.ts │ ├── invite.ts │ ├── invoice.ts │ ├── leaderboard.ts │ ├── leaderboardEntry.ts │ ├── organisation.ts │ ├── player.ts │ ├── playerAlias.ts │ ├── playerAuthActivity.ts │ ├── playerGameStat.ts │ ├── playerGroup.ts │ ├── playerHeadline.ts │ ├── playerPresence.ts │ ├── pricingPlan.ts │ ├── prop.ts │ └── user.ts ├── index.tsx ├── modals │ ├── ChannelDetails.tsx │ ├── ConfirmPlanChange.tsx │ ├── FeedbackCategoryDetails.tsx │ ├── IntegrationDetails.tsx │ ├── LeaderboardDetails.tsx │ ├── NewGame.tsx │ ├── NewInvite.tsx │ ├── Scopes.tsx │ ├── StatDetails.tsx │ ├── UpdateEntryScore.tsx │ ├── UpdateStatValue.tsx │ ├── __tests__ │ │ ├── ConfirmPlanChange.test.tsx │ │ ├── FeedbackCategoryDetails.test.tsx │ │ ├── IntegrationDetails.test.tsx │ │ ├── LeaderboardDetails.test.tsx │ │ ├── NewGame.test.tsx │ │ ├── Scopes.test.tsx │ │ └── StatDetails.test.tsx │ └── groups │ │ ├── GroupDetails.tsx │ │ ├── GroupRules.tsx │ │ └── __tests__ │ │ ├── GroupDetails.test.tsx │ │ ├── GroupRules.test.tsx │ │ └── unpackRules.test.tsx ├── pages │ ├── APIKeys.tsx │ ├── AcceptInvite.tsx │ ├── Account.tsx │ ├── Activity.tsx │ ├── Billing.tsx │ ├── Channels.tsx │ ├── ConfirmPassword.tsx │ ├── Dashboard.tsx │ ├── DataExports.tsx │ ├── Demo.tsx │ ├── EventBreakdown.tsx │ ├── EventsOverview.tsx │ ├── Feedback.tsx │ ├── FeedbackCategories.tsx │ ├── ForgotPassword.tsx │ ├── GameProps.tsx │ ├── GameSettings.tsx │ ├── Groups.tsx │ ├── Integrations.tsx │ ├── LeaderboardEntries.tsx │ ├── Leaderboards.tsx │ ├── Login.tsx │ ├── NotFound.tsx │ ├── Organisation.tsx │ ├── PlayerEvents.tsx │ ├── PlayerLeaderboardEntries.tsx │ ├── PlayerProfile.tsx │ ├── PlayerProps.tsx │ ├── PlayerSaveContent.tsx │ ├── PlayerSaves.tsx │ ├── PlayerStats.tsx │ ├── Players.tsx │ ├── RecoverAccount.tsx │ ├── Register.tsx │ ├── ResetPassword.tsx │ ├── Stats.tsx │ ├── Verify2FA.tsx │ └── __tests__ │ │ ├── Activity.test.tsx │ │ ├── Billing.test.tsx │ │ ├── Dashboard.test.tsx │ │ ├── ForgotPassword.test.tsx │ │ ├── Integrations.test.tsx │ │ ├── PlayerProps.test.tsx │ │ ├── Register.test.tsx │ │ └── ResetPassword.test.tsx ├── services │ ├── AuthService.ts │ └── __tests__ │ │ └── AuthService.test.ts ├── state │ ├── RecoilObserver.tsx │ ├── activeGameState.ts │ ├── devDataState.ts │ ├── gamesState.ts │ ├── justConfirmedEmailState.ts │ ├── organisationState.ts │ ├── saveDataNodeSizesState.ts │ └── userState.ts ├── styles │ ├── index.css │ └── theme.ts ├── utils │ ├── KitchenSink.tsx │ ├── __tests__ │ │ ├── buildError.test.ts │ │ ├── canPerformAction.test.ts │ │ ├── usePlayer.test.tsx │ │ ├── useSortedItems.test.tsx │ │ └── useTimePeriod.test.tsx │ ├── buildError.ts │ ├── canPerformAction.ts │ ├── canViewPage.ts │ ├── getEventColour.ts │ ├── group-rules │ │ ├── __tests__ │ │ │ ├── isGroupRuleValid.test.ts │ │ │ └── prepareRule.test.ts │ │ ├── isGroupRuleValid.ts │ │ ├── prepareRule.ts │ │ └── unpackRules.ts │ ├── useDaySections.ts │ ├── useIntendedRoute.ts │ ├── useLocalStorage.ts │ ├── useNodeGraph.ts │ ├── usePlayer.ts │ ├── useSearch.ts │ ├── useSortedItems.ts │ ├── useTimePeriod.ts │ └── validation │ │ └── nullableNumber.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | node_modules 4 | .env.development 5 | .eslintrc.json 6 | build 7 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://localhost:3000 2 | 3 | VITE_SENTRY_DSN= 4 | VITE_SENTRY_ENV=dev 5 | -------------------------------------------------------------------------------- /.env.e2e: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://talo.api 2 | 3 | VITE_SENTRY_DSN= 4 | VITE_SENTRY_ENV=dev 5 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | VITE_API_URL=${API_URL} 2 | 3 | VITE_SENTRY_DSN=${SENTRY_DSN} 4 | VITE_SENTRY_ENV=prod 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: TaloDev 2 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - release 5 | categories: 6 | - title: Breaking changes 7 | labels: 8 | - breaking 9 | - title: Features 10 | labels: 11 | - enhancement 12 | - title: Fixes 13 | labels: 14 | - fix 15 | - title: Other 16 | labels: 17 | - "*" 18 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 13 | 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | 18 | - uses: actions/cache@v4 19 | with: 20 | path: '**/node_modules' 21 | key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }} 22 | 23 | - name: Install deps 24 | run: npm ci --prefer-offline 25 | 26 | - name: Build 27 | env: 28 | ENABLE_SOURCEMAPS: true 29 | run: npm run build 30 | 31 | - name: Create GitHub release 32 | uses: softprops/action-gh-release@v2 33 | if: "!contains(github.event.head_commit.message, '--no-release')" 34 | with: 35 | generate_release_notes: true 36 | 37 | - name: Create Sentry release 38 | uses: getsentry/action-release@v1 39 | env: 40 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 41 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }} 42 | SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} 43 | with: 44 | environment: prod 45 | sourcemaps: './build' 46 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker build 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Set up QEMU 11 | uses: docker/setup-qemu-action@v3 12 | 13 | - name: Set up Docker Buildx 14 | uses: docker/setup-buildx-action@v3 15 | 16 | - name: Login to DockerHub 17 | uses: docker/login-action@v3 18 | with: 19 | registry: ghcr.io 20 | username: ${{ github.actor }} 21 | password: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | - name: Build and push 24 | id: docker_build 25 | uses: docker/build-push-action@v5 26 | with: 27 | platforms: linux/amd64,linux/arm64 28 | push: true 29 | tags: | 30 | ghcr.io/talodev/frontend:latest 31 | ghcr.io/talodev/frontend:${{ github.ref_name }} 32 | 33 | - name: Image digest 34 | run: echo ${{ steps.docker_build.outputs.digest }} 35 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Tag 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | tag: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Check version change 14 | id: check 15 | uses: EndBug/version-check@v2 16 | 17 | - name: Create tag 18 | if: steps.check.outputs.changed == 'true' 19 | uses: tvdias/github-tagger@v0.0.2 20 | with: 21 | repo-token: ${{ secrets.GITHUB_TOKEN }} 22 | tag: ${{ steps.check.outputs.version }} 23 | 24 | - name: Trigger Docker workflow 25 | if: steps.check.outputs.changed == 'true' 26 | uses: actions/github-script@v7 27 | with: 28 | script: | 29 | github.rest.actions.createWorkflowDispatch({ 30 | owner: context.repo.owner, 31 | repo: context.repo.repo, 32 | workflow_id: 'docker.yml', 33 | ref: '${{ steps.check.outputs.version }}' 34 | }) 35 | 36 | - name: Trigger Release workflow 37 | if: steps.check.outputs.changed == 'true' 38 | uses: actions/github-script@v7 39 | with: 40 | script: | 41 | github.rest.actions.createWorkflowDispatch({ 42 | owner: context.repo.owner, 43 | repo: context.repo.repo, 44 | workflow_id: 'create-release.yml', 45 | ref: '${{ steps.check.outputs.version }}' 46 | }) 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | *.log 4 | .DS_Store 5 | .eslintcache 6 | coverage 7 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Want to add a new system? Are the docs not clear enough? We're always accepting contributions so please share any new features, improvements or bug fixes with us. 4 | 5 | ## Installing, building & running 6 | 7 | Run `npm install` to install the dependencies. 8 | 9 | ### npm run dev 10 | 11 | Runs the app in the development mode. 12 | Open http://localhost:8080 to view it in the browser. 13 | 14 | ### npm run build 15 | 16 | Builds a static copy of the site to the `dist/` folder. 17 | 18 | ## Docker? 19 | 20 | We use Docker to build a production image of the dashboard (which is a simple NGINX server that hosts the static build files). It's not needed for development. 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS build 2 | WORKDIR /usr/frontend 3 | COPY . . 4 | 5 | # prepend \ to variables that need to be substituted 6 | # this prevents them from being substituted with empty values 7 | RUN sed -i 's/\${/\\${/g' .env.production 8 | 9 | RUN npm install 10 | RUN npm run build 11 | 12 | FROM caddy:2-alpine 13 | COPY --from=build /usr/frontend/dist /srv 14 | COPY /config/Caddyfile /etc/caddy/Caddyfile 15 | COPY --from=build /usr/frontend/config/entrypoint.sh /usr/local/bin/ 16 | 17 | RUN apk add --no-cache nodejs npm 18 | RUN npm i envsub -g 19 | RUN chmod +x /usr/local/bin/entrypoint.sh 20 | 21 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] 22 | 23 | EXPOSE 80 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Talo Platform Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/Caddyfile: -------------------------------------------------------------------------------- 1 | :80 { 2 | root * /srv 3 | encode gzip 4 | 5 | try_files {path} /index.html 6 | file_server 7 | 8 | handle_errors { 9 | rewrite * /index.html 10 | file_server 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /config/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | for filename in /srv/assets/*.js; do 3 | envsub --protect $filename $filename 4 | done 5 | 6 | caddy run --config /etc/caddy/Caddyfile 7 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | baseUrl: 'http://localhost:8080', 6 | setupNodeEvents(on, config) { 7 | // implement node event listeners here 8 | } 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /cypress/e2e/pages/AcceptInvite.spec.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('AcceptInvite', () => { 4 | it('should let users accept an invite', () => { 5 | cy.intercept('GET', /http:\/\/talo\.api\/public\/invites\/(.*)/, { 6 | statusCode: 200, 7 | body: { 8 | invite: { 9 | id: 1, 10 | email: 'dev@trytalo.com', 11 | organisation: { 12 | name: 'Sleepy Studios' 13 | }, 14 | invitedBy: 'Owner', 15 | createdAt: '2023-01-01 00:00:00' 16 | } 17 | } 18 | }) 19 | 20 | cy.intercept('POST', 'http://talo.api/public/users/register', { 21 | statusCode: 200, 22 | fixture: 'responses/auth/dev' 23 | }) 24 | 25 | cy.visitAsGuest('/accept/TRPEPI0BXC') 26 | cy.findByText('Sleepy Studios').should('exist') 27 | 28 | cy.findByText('Let\'s get started').should('exist') 29 | cy.findByLabelText('Username').type('sleepy') 30 | cy.findByLabelText('Password').type('password') 31 | 32 | cy.findByText('Sign up').should('be.disabled') 33 | cy.findByRole('checkbox').click() 34 | cy.findByText('Sign up').should('be.enabled') 35 | cy.findByText('Sign up').click() 36 | 37 | cy.findByText('Superstatic dashboard').should('exist') 38 | }) 39 | 40 | it('should handle missing invites', () => { 41 | cy.intercept('GET', /http:\/\/talo\.api\/public\/invites\/(.*)/, { 42 | statusCode: 404, 43 | body: { 44 | message: 'Invite not found' 45 | } 46 | }) 47 | 48 | cy.visitAsGuest('/accept/TRPEPI0BXC') 49 | cy.findByText('Invite not found').should('exist') 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /cypress/e2e/pages/Demo.spec.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Demo', () => { 4 | it('should let users enter the demo dashboard', () => { 5 | cy.intercept('POST', 'http://talo.api/public/demo', { 6 | statusCode: 200, 7 | fixture: 'responses/auth/dev' 8 | }) 9 | 10 | cy.visitAsGuest('/demo') 11 | cy.findByText('Launch demo').click() 12 | 13 | cy.findByText('Superstatic dashboard').should('exist') 14 | }) 15 | 16 | it('should handle errors', () => { 17 | cy.intercept('POST', 'http://talo.api/public/demo', { 18 | statusCode: 500, 19 | body: { 20 | message: 'Something went wrong' 21 | } 22 | }) 23 | 24 | cy.visitAsGuest('/demo') 25 | cy.findByText('Launch demo').click() 26 | 27 | cy.findByText('Something went wrong').should('exist') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /cypress/e2e/pages/ForgotPassword.spec.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('ForgotPassword', () => { 4 | it('should let users request a reset link', () => { 5 | cy.intercept('POST', 'http://talo.api/public/users/forgot_password', { 6 | statusCode: 204 7 | }) 8 | 9 | cy.visitAsGuest() 10 | cy.findByText('Forgot your password?').click() 11 | cy.findByLabelText('Email').type('admin@trytalo.com') 12 | cy.findByText('Confirm').click() 13 | 14 | cy.findByText('If an account exists for this email, you\'ll receive an email with instructions on how to reset your password').should('exist') 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /cypress/e2e/pages/Register.spec.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Register', () => { 4 | it('should let users register and create a new game', () => { 5 | cy.intercept('POST', 'http://talo.api/public/users/register', { 6 | statusCode: 200, 7 | fixture: 'responses/auth/register' 8 | }) 9 | 10 | cy.visitAsGuest() 11 | cy.findByText('Register here').click() 12 | 13 | cy.findByText('Let\'s get started').should('exist') 14 | cy.findByLabelText('Team or studio name').type('Sleepy Studios') 15 | cy.findByLabelText('Username').type('sleepy') 16 | cy.findByLabelText('Email').type('admin@trytalo.com') 17 | cy.findByLabelText('Password').type('password') 18 | 19 | cy.findByText('Sign up').should('be.disabled') 20 | cy.findByRole('checkbox').click() 21 | cy.findByText('Sign up').should('be.enabled') 22 | cy.findByText('Sign up').click() 23 | 24 | cy.findByText('New game').should('exist') 25 | cy.findByText('New game').click() 26 | 27 | cy.findByText('Create new game').should('exist') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /cypress/e2e/pages/ResetPassword.spec.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('ResetPassword', () => { 4 | it('should let users reset their password', () => { 5 | cy.intercept('POST', 'http://talo.api/public/users/reset_password', { 6 | statusCode: 204 7 | }) 8 | 9 | cy.visitAsGuest('/reset-password?token=abc123') 10 | cy.findByLabelText('New password').type('p@ssw0rd') 11 | cy.findByLabelText('Confirm password').type('p@ssw0rd1') 12 | 13 | cy.findByText('Confirm').should('be.disabled') 14 | cy.findByLabelText('New password').type('1') 15 | cy.findByText('Confirm').should('be.enabled') 16 | cy.findByText('Confirm').click() 17 | 18 | cy.findByText('Success! Your password has been reset').should('exist') 19 | cy.findByText('Go to Login').click() 20 | cy.findByText('Talo Game Services').should('exist') 21 | }) 22 | 23 | it('should let users login with valid credentials', () => { 24 | cy.intercept('POST', 'http://talo.api/public/users/reset_password', { 25 | statusCode: 401, 26 | body: { 27 | message: 'Request expired', 28 | expired: true 29 | } 30 | }) 31 | 32 | cy.visitAsGuest('/reset-password?token=abc123') 33 | cy.findByLabelText('New password').type('p@ssw0rd1') 34 | cy.findByLabelText('Confirm password').type('p@ssw0rd1') 35 | cy.findByText('Confirm').click() 36 | 37 | cy.findByText('please request a new reset link').should('exist') 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /cypress/e2e/pages/Verify2FA.spec.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Verify2FA', () => { 4 | it('should let 2fa users log in', () => { 5 | cy.intercept('POST', 'http://talo.api/public/users/login', { 6 | statusCode: 200, 7 | body: { 8 | twoFactorAuthRequired: true, 9 | userId: 1 10 | } 11 | }) 12 | 13 | cy.intercept('POST', 'http://talo.api/public/users/2fa', { fixture: 'responses/auth/dev' }) 14 | 15 | cy.visitAsGuest() 16 | cy.findByLabelText('Email').type('admin@trytalo.com') 17 | cy.findByLabelText('Password').type('password') 18 | cy.findByText('Login').click() 19 | 20 | cy.findByText('Two factor authentication').should('exist') 21 | cy.findByLabelText('Code').type('123456') 22 | cy.findByText('Confirm').click() 23 | 24 | cy.findByText('Superstatic dashboard').should('exist') 25 | }) 26 | 27 | it('should let 2fa users log in', () => { 28 | cy.intercept('POST', 'http://talo.api/public/users/login', { 29 | statusCode: 200, 30 | body: { 31 | twoFactorAuthRequired: true, 32 | userId: 1 33 | } 34 | }) 35 | 36 | cy.intercept('POST', 'http://talo.api/public/users/2fa', { 37 | statusCode: 403, 38 | body: { 39 | message: 'Invalid code' 40 | } 41 | }) 42 | 43 | cy.visitAsGuest() 44 | cy.findByLabelText('Email').type('admin@trytalo.com') 45 | cy.findByLabelText('Password').type('password') 46 | cy.findByText('Login').click() 47 | 48 | cy.findByText('Two factor authentication').should('exist') 49 | cy.findByLabelText('Code').type('123456') 50 | cy.findByText('Confirm').click() 51 | 52 | cy.findByText('Invalid code').should('exist') 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /cypress/fixtures/responses/auth/dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessToken": "ey...", 3 | "user": { 4 | "id": 1, 5 | "type": 2, 6 | "username": "Dev", 7 | "email": "dev@trytalo.com", 8 | "organisation": { 9 | "games": [ 10 | { 11 | "id": 1, 12 | "name": "Superstatic", 13 | "props": [], 14 | "playerCount": 0, 15 | "createdAt": "2023-01-01 00:00:00" 16 | } 17 | ] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cypress/fixtures/responses/auth/owner.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessToken": "ey...", 3 | "user": { 4 | "id": 1, 5 | "type": 0, 6 | "username": "Owner", 7 | "organisation": { 8 | "games": [ 9 | { 10 | "id": 1, 11 | "name": "Superstatic", 12 | "props": [], 13 | "playerCount": 0, 14 | "createdAt": "2023-01-01 00:00:00" 15 | } 16 | ], 17 | "pricingPlan": { 18 | "status": "active" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cypress/fixtures/responses/auth/register.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessToken": "ey...", 3 | "user": { 4 | "id": 1, 5 | "type": 2, 6 | "username": "sleepy", 7 | "organisation": { 8 | "games": [] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /cypress/fixtures/responses/billing/free-plan.json: -------------------------------------------------------------------------------- 1 | { 2 | "pricingPlan": { 3 | "pricingPlan": { 4 | "id": 1, 5 | "hidden": false, 6 | "default": true, 7 | "playerLimit": 10000 8 | }, 9 | "status": "active", 10 | "endDate": null, 11 | "canViewBillingPortal": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /cypress/fixtures/responses/billing/invoice.json: -------------------------------------------------------------------------------- 1 | { 2 | "invoice": { 3 | "lines": [ 4 | { 5 | "id": 1, 6 | "period": { 7 | "start": 1655024400, 8 | "end": 1657616400 9 | }, 10 | "description": "Team plan usage", 11 | "amount": 5000 12 | }, 13 | { 14 | "id": 2, 15 | "period": { 16 | "start": 1655024400, 17 | "end": 1657666799 18 | }, 19 | "description": "Team plan proration", 20 | "amount": -300 21 | }, 22 | { 23 | "id": 3, 24 | "period": { 25 | "start": 1657670400, 26 | "end": 1660345199 27 | }, 28 | "description": "Studio plan usage", 29 | "amount": 8000 30 | } 31 | ], 32 | "total": 13300, 33 | "prorationDate": 1655024400, 34 | "collectionDate": 1660345199 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /cypress/fixtures/responses/billing/usage.json: -------------------------------------------------------------------------------- 1 | { 2 | "usage": { 3 | "limit": 2, 4 | "used": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /cypress/fixtures/responses/game-stats/global.json: -------------------------------------------------------------------------------- 1 | { 2 | "stats": [ 3 | { 4 | "id": 1, 5 | "internalName": "damage-done", 6 | "name": "Damage done", 7 | "global": true, 8 | "globalValue": 56, 9 | "defaultValue": 0, 10 | "maxChange": 999, 11 | "minValue": 0, 12 | "maxValue": null, 13 | "minTimeBetweenUpdates": 0, 14 | "createdAt": "2023-01-01 00:00:00", 15 | "updatedAt": "2023-01-01 00:00:00" 16 | }, 17 | { 18 | "id": 2, 19 | "internalName": "treasures-found", 20 | "name": "Treasures found", 21 | "global": true, 22 | "globalValue": 14, 23 | "defaultValue": 0, 24 | "maxChange": 1, 25 | "minValue": 0, 26 | "maxValue": 30, 27 | "minTimeBetweenUpdates": 0, 28 | "createdAt": "2023-01-01 00:00:00", 29 | "updatedAt": "2023-01-01 00:00:00" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/cypress/add-commands' 2 | 3 | Cypress.Commands.add('stubDashboardCalls', () => { 4 | cy.intercept('GET', /http:\/\/talo\.api\/games\/1\/headlines\/average_session_duration/, { 5 | statusCode: 200, 6 | body: { 7 | hours: Cypress._.random(0, 2), 8 | minutes: Cypress._.random(0, 59), 9 | seconds: Cypress._.random(0, 59) 10 | } 11 | }) 12 | 13 | cy.intercept('GET', /http:\/\/talo\.api\/games\/1\/headlines\/(.*)/, { 14 | statusCode: 200, 15 | body: { 16 | count: Cypress._.random(0, 100) 17 | } 18 | }) 19 | 20 | cy.intercept('GET', 'http://talo.api/games/1/game-stats', { 21 | statusCode: 200, 22 | fixture: 'responses/game-stats/global' 23 | }) 24 | }) 25 | 26 | Cypress.Commands.add('visitAsGuest', (url = '/') => { 27 | cy.intercept('GET', 'http://talo.api/public/users/refresh', { 28 | statusCode: 401 29 | }) 30 | 31 | cy.stubDashboardCalls() 32 | 33 | cy.visit(url) 34 | }) 35 | 36 | Cypress.Commands.add('login', (userType = 'dev', url = '/') => { 37 | cy.intercept('GET', 'http://talo.api/public/users/refresh', { 38 | statusCode: 200, 39 | fixture: `responses/auth/${userType}` 40 | }) 41 | 42 | cy.stubDashboardCalls() 43 | 44 | cy.visit(url) 45 | }) 46 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Talo Dashboard 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | 'tailwindcss/nesting': {}, 4 | tailwindcss: {}, 5 | autoprefixer: {} 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TaloDev/frontend/eeef2e4c98ee84b8c34c23f2e7ae303ed5e3365a/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchDepTypes": ["devDependencies"], 9 | "matchUpdateTypes": ["minor", "patch"], 10 | "automerge": true 11 | }, 12 | { 13 | "matchDepTypes": ["dependencies"], 14 | "matchUpdateTypes": ["patch"], 15 | "enabled": false 16 | } 17 | ], 18 | "platformAutomerge": true, 19 | "schedule": ["before 3am on the first day of the month"] 20 | } 21 | -------------------------------------------------------------------------------- /setup-tests.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | 3 | globalThis.IS_REACT_ACT_ENVIRONMENT = true 4 | 5 | // eslint-disable-next-line no-undef 6 | process.env.VITE_API_URL = 'http://talo.api' 7 | -------------------------------------------------------------------------------- /src/__mocks__/gameFeedbackCategoryMock.ts: -------------------------------------------------------------------------------- 1 | import { GameFeedbackCategory } from '../entities/gameFeedbackCategory' 2 | 3 | export default function gameFeedbackCategoryMock(extra: Partial = {}): GameFeedbackCategory { 4 | return { 5 | id: 1, 6 | internalName: 'bugs', 7 | name: 'Bugs', 8 | description: 'Bug reports', 9 | anonymised: false, 10 | createdAt: new Date().toISOString(), 11 | updatedAt: new Date().toISOString(), 12 | ...extra 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/__mocks__/gameMock.ts: -------------------------------------------------------------------------------- 1 | import { Game } from '../entities/game' 2 | 3 | export default function gameMock(activeGameValue: { id: string, name: string }, extra: Partial = {}): Game { 4 | return { 5 | id: Number(activeGameValue.id), 6 | name: activeGameValue.name, 7 | props: [], 8 | playerCount: 0, 9 | createdAt: new Date().toISOString(), 10 | ...extra 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/__mocks__/gameStatMock.ts: -------------------------------------------------------------------------------- 1 | import { GameStat } from '../entities/gameStat' 2 | 3 | export default function gameStatMock(extra: Partial = {}): GameStat { 4 | return { 5 | id: 1, 6 | internalName: 'hearts-collected', 7 | name: 'Hearts collected', 8 | global: false, 9 | globalValue: 5, 10 | minValue: null, 11 | defaultValue: 5, 12 | maxValue: null, 13 | maxChange: null, 14 | minTimeBetweenUpdates: 0, 15 | createdAt: new Date().toISOString(), 16 | updatedAt: new Date().toISOString(), 17 | ...extra 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/__mocks__/leaderboardMock.ts: -------------------------------------------------------------------------------- 1 | import { Leaderboard, LeaderboardSortMode, LeaderboardRefreshInterval } from '../entities/leaderboard' 2 | 3 | export default function leaderboardMock(extra: Partial = {}): Leaderboard { 4 | return { 5 | id: 1, 6 | internalName: 'score', 7 | name: 'Score', 8 | sortMode: LeaderboardSortMode.ASC, 9 | unique: true, 10 | createdAt: new Date().toISOString(), 11 | updatedAt: new Date().toISOString(), 12 | refreshInterval: LeaderboardRefreshInterval.NEVER, 13 | ...extra 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/__mocks__/organisationMock.ts: -------------------------------------------------------------------------------- 1 | import { Organisation, statusSchema } from '../entities/organisation' 2 | 3 | export default function organisationMock(extra: Partial = {}): Organisation { 4 | return { 5 | id: 1, 6 | name: 'Sleepy Studios', 7 | games: [], 8 | pricingPlan: { 9 | status: statusSchema.enum.active 10 | }, 11 | ...extra 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/__mocks__/playerAliasMock.ts: -------------------------------------------------------------------------------- 1 | import { PlayerAlias, PlayerAliasService } from '../entities/playerAlias' 2 | import playerMock from './playerMock' 3 | 4 | export default function playerAliasMock(extra: Partial = {}): PlayerAlias { 5 | return { 6 | id: 1, 7 | service: PlayerAliasService.STEAM, 8 | identifier: 'yxre12', 9 | player: playerMock(), 10 | lastSeenAt: new Date().toISOString(), 11 | createdAt: new Date().toISOString(), 12 | updatedAt: new Date().toISOString(), 13 | ...extra 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/__mocks__/playerMock.ts: -------------------------------------------------------------------------------- 1 | import { Player } from '../entities/player' 2 | 3 | export default function playerMock(extra: Partial = {}): Player { 4 | return { 5 | id: '031fcd24-8ac4-4a64-ab5e-e86d05e7fe89', 6 | props: [], 7 | devBuild: true, 8 | createdAt: new Date().toISOString(), 9 | lastSeenAt: new Date().toISOString(), 10 | aliases: [], 11 | groups: [], 12 | presence: null, 13 | ...extra 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/__mocks__/pricingPlanMock.ts: -------------------------------------------------------------------------------- 1 | import { PricingPlanProduct } from '../entities/pricingPlan' 2 | 3 | export default function pricingPlanMock(extra: Partial = {}): PricingPlanProduct { 4 | return { 5 | id: 1, 6 | name: 'Team plan', 7 | prices: [ 8 | { amount: 5999, currency: 'usd', interval: 'year', current: false }, 9 | { amount: 499, currency: 'usd', interval: 'month', current: false } 10 | ], 11 | stripeId: 'plan_1', 12 | hidden: false, 13 | default: false, 14 | playerLimit: 10000, 15 | ...extra 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/__mocks__/userMock.ts: -------------------------------------------------------------------------------- 1 | import { User, UserType } from '../entities/user' 2 | import organisationMock from './organisationMock' 3 | 4 | export default function userMock(extra: Partial = {}): User { 5 | return { 6 | id: 1, 7 | email: 'dev@trytalo.com', 8 | username: 'dev', 9 | lastSeenAt: new Date().toISOString(), 10 | emailConfirmed: false, 11 | organisation: organisationMock(), 12 | type: UserType.DEV, 13 | has2fa: false, 14 | createdAt: new Date().toISOString(), 15 | ...extra 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/api/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import AuthService from '../services/AuthService' 3 | import refreshAccess from './refreshAccess' 4 | 5 | export const apiConfig = { 6 | withCredentials: true, 7 | baseURL: import.meta.env.VITE_API_URL 8 | } 9 | 10 | const instance = axios.create(apiConfig) 11 | 12 | instance.interceptors.request.use((config) => { 13 | if (AuthService.getToken()) { 14 | config.headers['Authorization'] = `Bearer ${AuthService.getToken()}` 15 | } 16 | 17 | const includeDevData = (window.localStorage.getItem('includeDevDataOptimistic') ?? 18 | window.localStorage.getItem('includeDevData')) === 'true' 19 | 20 | config.headers['X-Talo-Include-Dev-Data'] = includeDevData ? '1' : '0' 21 | 22 | return config 23 | }, (error) => Promise.reject(error) 24 | ) 25 | 26 | instance.interceptors.response.use((response) => { 27 | return response 28 | }, async (error) => { 29 | const request = error.config 30 | 31 | if (!request.url.startsWith('/public')) { 32 | if (error.response?.status === 401 && !request._retry) { 33 | request._retry = true 34 | 35 | const { accessToken } = await refreshAccess() 36 | const newToken = accessToken 37 | 38 | AuthService.setToken(newToken) 39 | axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}` 40 | 41 | return instance(request) 42 | } else if (error.response?.status === 401 && request._retry) { 43 | AuthService.reload() 44 | } 45 | } 46 | 47 | return Promise.reject(error) 48 | }) 49 | 50 | export default instance 51 | -------------------------------------------------------------------------------- /src/api/changePassword.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const changePassword = makeValidatedRequest( 6 | (currentPassword: string, newPassword: string) => api.post('/users/change_password', { currentPassword, newPassword }), 7 | z.object({ 8 | accessToken: z.string() 9 | }) 10 | ) 11 | 12 | export default changePassword 13 | -------------------------------------------------------------------------------- /src/api/confirmEmail.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { userSchema } from '../entities/user' 5 | 6 | const confirmEmail = makeValidatedRequest( 7 | (code: string) => api.post('/users/confirm_email', { code }), 8 | z.object({ 9 | user: userSchema 10 | }) 11 | ) 12 | 13 | export default confirmEmail 14 | -------------------------------------------------------------------------------- /src/api/confirmEnable2FA.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { userSchema } from '../entities/user' 5 | 6 | const confirmEnable2FA = makeValidatedRequest( 7 | (code: string) => api.post('/users/2fa/enable', { code }), 8 | z.object({ 9 | user: userSchema, 10 | recoveryCodes: z.array(z.string()) 11 | }) 12 | ) 13 | 14 | export default confirmEnable2FA 15 | 16 | -------------------------------------------------------------------------------- /src/api/confirmPlan.ts: -------------------------------------------------------------------------------- 1 | import api from './api' 2 | 3 | const confirmPlan = (prorationDate: number, pricingPlanId: number, pricingInterval: string) => 4 | api.post('/billing/confirm-plan', { prorationDate, pricingPlanId, pricingInterval }) 5 | 6 | export default confirmPlan 7 | -------------------------------------------------------------------------------- /src/api/createAPIKey.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { apiKeySchema } from '../entities/apiKey' 5 | 6 | const createAPIKey = makeValidatedRequest( 7 | (gameId: number, scopes: string[]) => api.post(`/games/${gameId}/api-keys`, { scopes }), 8 | z.object({ 9 | token: z.string(), 10 | apiKey: apiKeySchema 11 | }) 12 | ) 13 | 14 | export default createAPIKey 15 | 16 | -------------------------------------------------------------------------------- /src/api/createChannel.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { GameChannel, gameChannelSchema } from '../entities/gameChannels' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | type Data = Pick & { ownerAliasId: number | null } 7 | 8 | const createChannel = makeValidatedRequest( 9 | (gameId: number, data: Data) => api.post(`/games/${gameId}/game-channels`, data), 10 | z.object({ 11 | channel: gameChannelSchema 12 | }) 13 | ) 14 | 15 | export default createChannel 16 | -------------------------------------------------------------------------------- /src/api/createCheckoutSession.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { invoiceSchema } from '../entities/invoice' 5 | 6 | const createCheckoutSession = makeValidatedRequest( 7 | (pricingPlanId, pricingInterval) => api.post('/billing/checkout-session', { pricingPlanId, pricingInterval }), 8 | z.object({ 9 | redirect: z.string().url().optional(), 10 | invoice: invoiceSchema.optional() 11 | }) 12 | ) 13 | 14 | export default createCheckoutSession 15 | -------------------------------------------------------------------------------- /src/api/createDataExport.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { dataExportSchema } from '../entities/dataExport' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | const createDataExport = makeValidatedRequest( 7 | (gameId, entities) => api.post(`/games/${gameId}/data-exports`, { entities }), 8 | z.object({ 9 | dataExport: dataExportSchema 10 | }) 11 | ) 12 | 13 | export default createDataExport 14 | -------------------------------------------------------------------------------- /src/api/createDemo.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { userSchema } from '../entities/user' 5 | 6 | const createDemo = makeValidatedRequest( 7 | () => api.post('/public/demo'), 8 | z.object({ 9 | accessToken: z.string(), 10 | user: userSchema 11 | }) 12 | ) 13 | 14 | export default createDemo 15 | -------------------------------------------------------------------------------- /src/api/createFeedbackCategory.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { GameFeedbackCategory, gameFeedbackCategorySchema } from '../entities/gameFeedbackCategory' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | type Data = Pick 7 | 8 | const createFeedbackCategory = makeValidatedRequest( 9 | (gameId: number, data: Data) => api.post(`/games/${gameId}/game-feedback/categories`, data), 10 | z.object({ 11 | feedbackCategory: gameFeedbackCategorySchema 12 | }) 13 | ) 14 | 15 | export default createFeedbackCategory 16 | -------------------------------------------------------------------------------- /src/api/createGame.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { gameSchema } from '../entities/game' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | const createGame = makeValidatedRequest( 7 | (name: string) => api.post('/games', { name }), 8 | z.object({ 9 | game: gameSchema 10 | }) 11 | ) 12 | 13 | export default createGame 14 | -------------------------------------------------------------------------------- /src/api/createGroup.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { PlayerGroup, playerGroupSchema } from '../entities/playerGroup' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | type Data = Pick 7 | 8 | const createGroup = makeValidatedRequest( 9 | (gameId: number, data: Data) => api.post(`/games/${gameId}/player-groups`, data), 10 | z.object({ 11 | group: playerGroupSchema 12 | }) 13 | ) 14 | 15 | export default createGroup 16 | -------------------------------------------------------------------------------- /src/api/createInvite.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { UserType } from '../entities/user' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | import { inviteSchema } from '../entities/invite' 6 | 7 | const createInvite = makeValidatedRequest( 8 | (email: string, type: UserType) => api.post('/invites', { email, type }), 9 | z.object({ 10 | invite: inviteSchema 11 | }) 12 | ) 13 | 14 | export default createInvite 15 | -------------------------------------------------------------------------------- /src/api/createLeaderboard.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { Leaderboard, leaderboardSchema } from '../entities/leaderboard' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | type Data = Pick 7 | 8 | const createLeaderboard = makeValidatedRequest( 9 | (gameId: number, data: Data) => api.post(`/games/${gameId}/leaderboards`, data), 10 | z.object({ 11 | leaderboard: leaderboardSchema 12 | }) 13 | ) 14 | 15 | export default createLeaderboard 16 | -------------------------------------------------------------------------------- /src/api/createPortalSession.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const createPortalSession = makeValidatedRequest( 6 | () => api.post('/billing/portal-session'), 7 | z.object({ 8 | redirect: z.string().url() 9 | }) 10 | ) 11 | 12 | export default createPortalSession 13 | -------------------------------------------------------------------------------- /src/api/createRecoveryCodes.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const createRecoveryCodes = makeValidatedRequest( 6 | (password: string) => api.post('/users/2fa/recovery_codes/create', { password }), 7 | z.object({ 8 | recoveryCodes: z.array(z.string()) 9 | }) 10 | ) 11 | 12 | export default createRecoveryCodes 13 | -------------------------------------------------------------------------------- /src/api/createStat.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { GameStat, gameStatSchema } from '../entities/gameStat' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | type Data = Pick 15 | 16 | const createStat = makeValidatedRequest( 17 | (gameId: number, data: Data) => api.post(`/games/${gameId}/game-stats`, data), 18 | z.object({ 19 | stat: gameStatSchema 20 | }) 21 | ) 22 | 23 | export default createStat 24 | -------------------------------------------------------------------------------- /src/api/deleteAPIKey.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const deleteAPIKey = makeValidatedRequest( 6 | (gameId: number, apiKeyId: number) => api.delete(`/games/${gameId}/api-keys/${apiKeyId}`), 7 | z.literal('') 8 | ) 9 | 10 | export default deleteAPIKey 11 | -------------------------------------------------------------------------------- /src/api/deleteChannel.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const deleteChannel = makeValidatedRequest( 6 | (gameId: number, channelId: number) => api.delete(`/games/${gameId}/game-channels/${channelId}`), 7 | z.literal('') 8 | ) 9 | 10 | export default deleteChannel 11 | -------------------------------------------------------------------------------- /src/api/deleteFeedbackCategory.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const deleteFeedbackCategory = makeValidatedRequest( 6 | (gameId: number, feedbackCategoryId: number) => api.delete(`/games/${gameId}/game-feedback/categories/${feedbackCategoryId}`), 7 | z.literal('') 8 | ) 9 | 10 | export default deleteFeedbackCategory 11 | -------------------------------------------------------------------------------- /src/api/deleteGroup.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const deleteGroup = makeValidatedRequest( 6 | (gameId: number, groupId: string) => api.delete(`/games/${gameId}/player-groups/${groupId}`), 7 | z.literal('') 8 | ) 9 | 10 | export default deleteGroup 11 | -------------------------------------------------------------------------------- /src/api/deleteLeaderboard.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const deleteLeaderboard = makeValidatedRequest( 6 | (gameId: number, leaderboardId: number) => api.delete(`/games/${gameId}/leaderboards/${leaderboardId}`), 7 | z.literal('') 8 | ) 9 | 10 | export default deleteLeaderboard 11 | -------------------------------------------------------------------------------- /src/api/deleteStat.ts: -------------------------------------------------------------------------------- 1 | import api from './api' 2 | import { z } from 'zod' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const deleteStat = makeValidatedRequest( 6 | (gameId: number, statId: number) => api.delete(`/games/${gameId}/game-stats/${statId}`), 7 | z.literal('') 8 | ) 9 | 10 | export default deleteStat 11 | -------------------------------------------------------------------------------- /src/api/disable2FA.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { userSchema } from '../entities/user' 5 | 6 | const disable2FA = makeValidatedRequest( 7 | (password) => api.post('/users/2fa/disable', { password }), 8 | z.object({ 9 | user: userSchema 10 | }) 11 | ) 12 | 13 | export default disable2FA 14 | -------------------------------------------------------------------------------- /src/api/disableIntegration.ts: -------------------------------------------------------------------------------- 1 | import api from './api' 2 | import { z } from 'zod' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const disableIntegration = makeValidatedRequest( 6 | (gameId: number, integrationId: number) => api.delete(`/games/${gameId}/integrations/${integrationId}`), 7 | z.literal('') 8 | ) 9 | 10 | export default disableIntegration 11 | -------------------------------------------------------------------------------- /src/api/enable2FA.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const enable2FA = makeValidatedRequest( 6 | () => api.get('/users/2fa/enable'), 7 | z.object({ 8 | qr: z.string() 9 | }) 10 | ) 11 | 12 | export default enable2FA 13 | -------------------------------------------------------------------------------- /src/api/enableIntegration.ts: -------------------------------------------------------------------------------- 1 | import api from './api' 2 | import { z } from 'zod' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { Integration, integrationSchema } from '../entities/integration' 5 | 6 | const enableIntegration = makeValidatedRequest( 7 | (gameId: number, data: Pick) => api.post(`/games/${gameId}/integrations`, data), 8 | z.object({ 9 | integration: integrationSchema 10 | }) 11 | ) 12 | 13 | export default enableIntegration 14 | -------------------------------------------------------------------------------- /src/api/findLeaderboard.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { leaderboardSchema } from '../entities/leaderboard' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | const findLeaderboard = makeValidatedRequest( 7 | (gameId: number, internalName: string) => api.get(`/games/${gameId}/leaderboards/search?internalName=${internalName}`), 8 | z.object({ 9 | leaderboard: leaderboardSchema 10 | }) 11 | ) 12 | 13 | export default findLeaderboard 14 | -------------------------------------------------------------------------------- /src/api/findPlayer.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { playerSchema } from '../entities/player' 5 | 6 | const findPlayer = makeValidatedRequest( 7 | (gameId: number, playerId: string) => api.get(`/games/${gameId}/players?search=${playerId}`), 8 | z.object({ 9 | players: z.array(playerSchema) 10 | }) 11 | ) 12 | 13 | export default findPlayer 14 | -------------------------------------------------------------------------------- /src/api/getInvite.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { inviteSchema } from '../entities/invite' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | const getInvite = makeValidatedRequest( 7 | (token: string) => api.get(`/public/invites/${token}`), 8 | z.object({ 9 | invite: inviteSchema 10 | }) 11 | ) 12 | 13 | export default getInvite 14 | -------------------------------------------------------------------------------- /src/api/login.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { userSchema } from '../entities/user' 5 | 6 | const twoFactorAuthResponseSchema = z.object({ 7 | twoFactorAuthRequired: z.literal(true), 8 | userId: z.number() 9 | }) 10 | 11 | const loginSuccessResponseSchema = z.object({ 12 | accessToken: z.string(), 13 | user: userSchema 14 | }) 15 | 16 | const login = makeValidatedRequest( 17 | (data: { email: string, password: string }) => api.post('/public/users/login', data), 18 | z.union([twoFactorAuthResponseSchema, loginSuccessResponseSchema]) 19 | ) 20 | 21 | export default login 22 | -------------------------------------------------------------------------------- /src/api/logout.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const logout = makeValidatedRequest( 6 | () => api.post('/users/logout'), 7 | z.literal('') 8 | ) 9 | 10 | export default logout 11 | -------------------------------------------------------------------------------- /src/api/makeValidatedGetRequest.ts: -------------------------------------------------------------------------------- 1 | import { ZodType } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | export default function makeValidatedGetRequest(url: string, validator: T) { 6 | return makeValidatedRequest(() => api.get(url), validator)() 7 | } 8 | -------------------------------------------------------------------------------- /src/api/makeValidatedRequest.ts: -------------------------------------------------------------------------------- 1 | import { captureException } from '@sentry/react' 2 | import { AxiosResponse } from 'axios' 3 | import { z, ZodError } from 'zod' 4 | import type { ZodType } from 'zod' 5 | 6 | export default function makeValidatedRequest( 7 | doFetch: (...args: A) => Promise, 8 | validator: T 9 | ): (...args: A) => Promise> { 10 | return async (...args) => { 11 | const res = await doFetch(...args) 12 | const rawResult = res.data 13 | 14 | try { 15 | return await validator.parseAsync(rawResult) 16 | } catch (parseError) { 17 | if (parseError instanceof ZodError) { 18 | console.error(`Response body (${res.request.responseURL}) is invalid`, parseError.errors.map((error) => ({ 19 | ...error, 20 | path: error.path.join('.') 21 | }))) 22 | } 23 | 24 | captureException(parseError) 25 | return rawResult 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/api/recoverAccount.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { userSchema } from '../entities/user' 5 | 6 | const recoverAccount = makeValidatedRequest( 7 | (code: string, userId: number) => api.post('/public/users/2fa/recover', { code, userId }), 8 | z.object({ 9 | accessToken: z.string(), 10 | user: userSchema, 11 | newRecoveryCodes: z.array(z.string()).optional() 12 | }) 13 | ) 14 | 15 | export default recoverAccount 16 | -------------------------------------------------------------------------------- /src/api/refreshAccess.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import AuthService from '../services/AuthService' 3 | import { apiConfig } from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | import { z } from 'zod' 6 | import { userSchema } from '../entities/user' 7 | 8 | const refreshAccess = makeValidatedRequest( 9 | () => { 10 | const api = axios.create(apiConfig) 11 | 12 | api.interceptors.response.use((response) => { 13 | return response 14 | }, (error) => { 15 | if (AuthService.getToken() && error.response?.status === 401) AuthService.reload() 16 | return Promise.reject(error) 17 | }) 18 | 19 | return api.get('/public/users/refresh') 20 | }, 21 | z.object({ 22 | accessToken: z.string(), 23 | user: userSchema 24 | }) 25 | ) 26 | 27 | export default refreshAccess 28 | -------------------------------------------------------------------------------- /src/api/register.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { userSchema } from '../entities/user' 5 | 6 | export type Data = { 7 | email: string 8 | password: string 9 | organisationName?: string 10 | username: string 11 | inviteToken?: string 12 | } 13 | 14 | const register = makeValidatedRequest( 15 | (data: Data) => api.post('/public/users/register', data), 16 | z.object({ 17 | accessToken: z.string(), 18 | user: userSchema 19 | }) 20 | ) 21 | 22 | export default register 23 | -------------------------------------------------------------------------------- /src/api/requestNewPassword.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const requestNewPassword = makeValidatedRequest( 6 | (email: string) => api.post('/public/users/forgot_password', { email }), 7 | z.literal('') 8 | ) 9 | 10 | export default requestNewPassword 11 | -------------------------------------------------------------------------------- /src/api/resetPassword.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const resetPassword = makeValidatedRequest( 6 | (token: string, password: string) => api.post('/public/users/reset_password', { token, password }), 7 | z.literal('') 8 | ) 9 | 10 | export default resetPassword 11 | -------------------------------------------------------------------------------- /src/api/syncLeaderboards.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const syncLeaderboards = makeValidatedRequest( 6 | (gameId: number, integrationId: number) => api.post(`/games/${gameId}/integrations/${integrationId}/sync-leaderboards`), 7 | z.literal('') 8 | ) 9 | 10 | export default syncLeaderboards 11 | -------------------------------------------------------------------------------- /src/api/syncStats.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const syncStats = makeValidatedRequest( 6 | (gameId: number, integrationId: number) => api.post(`/games/${gameId}/integrations/${integrationId}/sync-stats`), 7 | z.literal('') 8 | ) 9 | 10 | export default syncStats 11 | -------------------------------------------------------------------------------- /src/api/toggledPinnedGroup.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const togglePinnedGroup = makeValidatedRequest( 6 | (gameId: number, groupId: string, pinned: boolean) => api.put(`/games/${gameId}/player-groups/${groupId}/toggle-pinned`, { pinned }), 7 | z.literal('') 8 | ) 9 | 10 | export default togglePinnedGroup 11 | -------------------------------------------------------------------------------- /src/api/updateAPIKey.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { apiKeySchema } from '../entities/apiKey' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | const updateAPIKey = makeValidatedRequest( 7 | (gameId: number, apiKeyId: number, data: { scopes: string[] }) => api.put(`/games/${gameId}/api-keys/${apiKeyId}`, data), 8 | z.object({ 9 | apiKey: apiKeySchema 10 | }) 11 | ) 12 | 13 | export default updateAPIKey 14 | -------------------------------------------------------------------------------- /src/api/updateChannel.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import { GameChannel, gameChannelSchema } from '../entities/gameChannels' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | type Data = Pick & { ownerAliasId: number | null } 7 | 8 | const updateChannel = makeValidatedRequest( 9 | (gameId: number, channelId: number, data: Data) => api.put(`/games/${gameId}/game-channels/${channelId}`, data), 10 | z.object({ 11 | channel: gameChannelSchema 12 | }) 13 | ) 14 | 15 | export default updateChannel 16 | -------------------------------------------------------------------------------- /src/api/updateFeedbackCategory.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { GameFeedbackCategory, gameFeedbackCategorySchema } from '../entities/gameFeedbackCategory' 5 | 6 | type Data = Pick 7 | 8 | const updateFeedbackCategory = makeValidatedRequest( 9 | (gameId: number, feedbackCategoryId: number, data: Data) => api.put(`/games/${gameId}/game-feedback/categories/${feedbackCategoryId}`, data), 10 | z.object({ 11 | feedbackCategory: gameFeedbackCategorySchema 12 | }) 13 | ) 14 | 15 | export default updateFeedbackCategory 16 | -------------------------------------------------------------------------------- /src/api/updateGame.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { gameSchema } from '../entities/game' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | import { Prop } from '../entities/prop' 6 | 7 | type Data = { 8 | name?: string 9 | props?: Prop[] 10 | purgeDevPlayers?: boolean 11 | purgeLivePlayers?: boolean 12 | website?: string | null 13 | } 14 | 15 | const updateGame = makeValidatedRequest( 16 | (gameId: number, data: Data) => api.patch(`/games/${gameId}`, data), 17 | z.object({ 18 | game: gameSchema 19 | }) 20 | ) 21 | 22 | export default updateGame 23 | -------------------------------------------------------------------------------- /src/api/updateGroup.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { PlayerGroup, playerGroupSchema } from '../entities/playerGroup' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | type Data = Pick 7 | 8 | const updateGroup = makeValidatedRequest( 9 | (gameId: number, groupId: string, data: Data) => api.put(`/games/${gameId}/player-groups/${groupId}`, data), 10 | z.object({ 11 | group: playerGroupSchema 12 | }) 13 | ) 14 | 15 | export default updateGroup 16 | -------------------------------------------------------------------------------- /src/api/updateIntegration.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { IntegrationConfig, integrationSchema } from '../entities/integration' 5 | 6 | const updateIntegration = makeValidatedRequest( 7 | (gameId: number, integrationId: number, data: { config: Partial }) => 8 | api.patch(`/games/${gameId}/integrations/${integrationId}`, data), 9 | z.object({ 10 | integration: integrationSchema 11 | }) 12 | ) 13 | 14 | export default updateIntegration 15 | -------------------------------------------------------------------------------- /src/api/updateLeaderboard.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import { Leaderboard, leaderboardSchema } from '../entities/leaderboard' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | type Data = Pick 7 | 8 | const updateLeaderboard = makeValidatedRequest( 9 | (gameId: number, leaderboardId: number, data: Data) => api.put(`/games/${gameId}/leaderboards/${leaderboardId}`, data), 10 | z.object({ 11 | leaderboard: leaderboardSchema 12 | }) 13 | ) 14 | 15 | export default updateLeaderboard 16 | -------------------------------------------------------------------------------- /src/api/updateLeaderboardEntry.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { leaderboardEntrySchema } from '../entities/leaderboardEntry' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | const updateLeaderboardEntry = makeValidatedRequest( 7 | (gameId: number, leaderboardId: number, entryId: number, data: { hidden?: boolean, newScore?: number }) => 8 | api.patch(`/games/${gameId}/leaderboards/${leaderboardId}/entries/${entryId}`, data), 9 | z.object({ 10 | entry: leaderboardEntrySchema 11 | }) 12 | ) 13 | 14 | export default updateLeaderboardEntry 15 | -------------------------------------------------------------------------------- /src/api/updatePlayer.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { playerSchema } from '../entities/player' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | import { Prop } from '../entities/prop' 6 | 7 | const updatePlayer = makeValidatedRequest( 8 | (gameId: number, playerId: string, data: { props: Prop[] }) => api.patch(`/games/${gameId}/players/${playerId}`, data), 9 | z.object({ 10 | player: playerSchema 11 | }) 12 | ) 13 | 14 | export default updatePlayer 15 | -------------------------------------------------------------------------------- /src/api/updateStat.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { GameStat, gameStatSchema } from '../entities/gameStat' 3 | import api from './api' 4 | import makeValidatedRequest from './makeValidatedRequest' 5 | 6 | type Data = Pick 15 | 16 | const updateStat = makeValidatedRequest( 17 | (gameId: number, statId: number, data: Data) => api.put(`/games/${gameId}/game-stats/${statId}`, data), 18 | z.object({ 19 | stat: gameStatSchema 20 | }) 21 | ) 22 | 23 | export default updateStat 24 | -------------------------------------------------------------------------------- /src/api/updateStatValue.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { playerGameStatSchema } from '../entities/playerGameStat' 5 | 6 | const updateStatValue = makeValidatedRequest( 7 | (gameId: number, statId: number, playerStatId: number, newValue: number) => { 8 | return api.patch(`/games/${gameId}/game-stats/${statId}/player-stats/${playerStatId}`, { newValue }) 9 | }, 10 | z.object({ 11 | playerStat: playerGameStatSchema 12 | }) 13 | ) 14 | 15 | export default updateStatValue 16 | -------------------------------------------------------------------------------- /src/api/useAPIKeys.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import { z } from 'zod' 5 | import { apiKeySchema } from '../entities/apiKey' 6 | import makeValidatedGetRequest from './makeValidatedGetRequest' 7 | 8 | export default function useAPIKeys(activeGame: Game) { 9 | const fetcher = async ([url]: [string]) => { 10 | const { apiKeys } = await makeValidatedGetRequest(url, z.object({ 11 | apiKeys: z.array(apiKeySchema) 12 | })) 13 | const { scopes } = await makeValidatedGetRequest(`${url}/scopes`, z.object({ 14 | scopes: z.record(z.array(z.string())) 15 | })) 16 | 17 | return { 18 | apiKeys, 19 | scopes 20 | } 21 | } 22 | 23 | const { data, error, mutate } = useSWR( 24 | [`/games/${activeGame.id}/api-keys`], 25 | fetcher 26 | ) 27 | 28 | return { 29 | apiKeys: data?.apiKeys ?? [], 30 | scopes: data?.scopes ?? {}, 31 | loading: !data && !error, 32 | error: error && buildError(error), 33 | mutate 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/api/useChannels.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { gameChannelSchema } from '../entities/gameChannels' 7 | 8 | export const channelsSchema = z.object({ 9 | channels: z.array(gameChannelSchema), 10 | count: z.number(), 11 | itemsPerPage: z.number(), 12 | isLastPage: z.boolean() 13 | }) 14 | 15 | export default function useChannels(activeGame: Game, search: string, page: number) { 16 | const fetcher = async ([url]: [string]) => { 17 | const params = new URLSearchParams({ page: String(page), search }) 18 | const res = await makeValidatedGetRequest(`${url}?${params.toString()}`, channelsSchema) 19 | return res 20 | } 21 | 22 | const { data, error, mutate } = useSWR( 23 | [`/games/${activeGame.id}/game-channels`, search, page], 24 | fetcher 25 | ) 26 | 27 | return { 28 | channels: data?.channels ?? [], 29 | count: data?.count, 30 | itemsPerPage: data?.itemsPerPage, 31 | loading: !data && !error, 32 | error: error && buildError(error), 33 | mutate 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/api/useDataExportEntities.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import { z } from 'zod' 5 | import { DataExportAvailableEntities } from '../entities/dataExport' 6 | import makeValidatedGetRequest from './makeValidatedGetRequest' 7 | 8 | export default function useDataExportEntities(activeGame: Game) { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | entities: z.array(z.nativeEnum(DataExportAvailableEntities)) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error } = useSWR( 18 | [`/games/${activeGame.id}/data-exports/entities`], 19 | fetcher 20 | ) 21 | 22 | return { 23 | entities: data?.entities ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/api/useDataExports.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { dataExportSchema } from '../entities/dataExport' 6 | import { z } from 'zod' 7 | 8 | const useDataExports = (activeGame: Game, createdExportId: number | null) => { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | dataExports: z.array(dataExportSchema) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error } = useSWR( 18 | [`/games/${activeGame.id}/data-exports`, createdExportId], 19 | fetcher 20 | ) 21 | 22 | return { 23 | dataExports: data?.dataExports ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error) 26 | } 27 | } 28 | 29 | export default useDataExports 30 | -------------------------------------------------------------------------------- /src/api/useEventBreakdown.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { eventsVisualisationPayloadSchema } from './useEvents' 7 | 8 | export default function useEventBreakdown(activeGame: Game, eventName: string, startDate: string, endDate: string) { 9 | const fetcher = async ([url]: [string]) => { 10 | const qs = new URLSearchParams({ 11 | eventName, 12 | startDate, 13 | endDate 14 | }).toString() 15 | 16 | const res = await makeValidatedGetRequest(`${url}?${qs}`, z.object({ 17 | events: z.record( 18 | z.array(eventsVisualisationPayloadSchema) 19 | ), 20 | eventNames: z.array(z.string()) 21 | })) 22 | 23 | return res 24 | } 25 | 26 | const { data, error } = useSWR( 27 | activeGame && eventName && startDate && endDate ? [`/games/${activeGame.id}/events/breakdown`, eventName, startDate, endDate] : null, 28 | fetcher 29 | ) 30 | 31 | return { 32 | events: data?.events, 33 | eventNames: data?.eventNames ?? [], 34 | loading: !data && !error, 35 | error: error && buildError(error) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/api/useEvents.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | 7 | export const eventsVisualisationPayloadSchema = z.object({ 8 | name: z.string(), 9 | date: z.number(), 10 | count: z.number(), 11 | change: z.number() 12 | }) 13 | 14 | export default function useEvents(activeGame: Game, startDate: string, endDate: string) { 15 | const fetcher = async ([url]: [string]) => { 16 | const qs = new URLSearchParams({ 17 | startDate, 18 | endDate 19 | }).toString() 20 | 21 | const res = await makeValidatedGetRequest(`${url}?${qs}`, z.object({ 22 | events: z.record( 23 | z.array(eventsVisualisationPayloadSchema) 24 | ), 25 | eventNames: z.array(z.string()) 26 | })) 27 | 28 | return res 29 | } 30 | 31 | const { data, error } = useSWR( 32 | activeGame && startDate && endDate ? [`/games/${activeGame.id}/events`, startDate, endDate] : null, 33 | fetcher 34 | ) 35 | 36 | return { 37 | events: data?.events, 38 | eventNames: data?.eventNames ?? [], 39 | loading: !data && !error, 40 | error: error && buildError(error) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/api/useFeedback.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { gameFeedbackSchema } from '../entities/gameFeedback' 6 | import { z } from 'zod' 7 | 8 | export default function useFeedback(activeGame: Game, feedbackCategoryInternalName: string | null, search: string, page: number) { 9 | const fetcher = async ([url]: [string]) => { 10 | const params = new URLSearchParams({ page: String(page), search }) 11 | if (feedbackCategoryInternalName) { 12 | params.append('feedbackCategoryInternalName', feedbackCategoryInternalName) 13 | } 14 | 15 | const res = await makeValidatedGetRequest(`${url}?${params.toString()}`, z.object({ 16 | feedback: z.array(gameFeedbackSchema), 17 | count: z.number(), 18 | itemsPerPage: z.number() 19 | })) 20 | 21 | return res 22 | } 23 | 24 | const { data, error } = useSWR( 25 | [`/games/${activeGame.id}/game-feedback`, feedbackCategoryInternalName, search, page], 26 | fetcher 27 | ) 28 | 29 | return { 30 | feedback: data?.feedback ?? [], 31 | count: data?.count, 32 | itemsPerPage: data?.itemsPerPage, 33 | loading: !data && !error, 34 | error: error && buildError(error) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/api/useFeedbackCategories.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { gameFeedbackCategorySchema } from '../entities/gameFeedbackCategory' 6 | import { z } from 'zod' 7 | 8 | export default function useFeedbackCategories(activeGame: Game) { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | feedbackCategories: z.array(gameFeedbackCategorySchema) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error, mutate } = useSWR( 18 | [`/games/${activeGame.id}/game-feedback/categories`], 19 | fetcher 20 | ) 21 | 22 | return { 23 | feedbackCategories: data?.feedbackCategories ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error), 26 | mutate 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/api/useGameActivities.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { gameActivitySchema } from '../entities/gameActivity' 7 | 8 | export default function useGameActivities(activeGame: Game) { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | activities: z.array(gameActivitySchema) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error } = useSWR( 18 | [`/games/${activeGame.id}/game-activities`], 19 | fetcher 20 | ) 21 | 22 | return { 23 | activities: data?.activities ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/api/useGameSettings.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | 7 | export default function useGameSettings(activeGame: Game) { 8 | const fetcher = async ([url]: [string]) => { 9 | const res = await makeValidatedGetRequest(url, z.object({ 10 | settings: z.object({ 11 | purgeDevPlayers: z.boolean(), 12 | purgeLivePlayers: z.boolean(), 13 | website: z.string().nullable() 14 | }) 15 | })) 16 | 17 | return res 18 | } 19 | 20 | const { data, error } = useSWR( 21 | [`/games/${activeGame.id}/settings`], 22 | fetcher 23 | ) 24 | 25 | return { 26 | settings: data?.settings, 27 | loading: !data && !error, 28 | error: error && buildError(error) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/api/useGroupPreviewCount.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import prepareRule from '../utils/group-rules/prepareRule' 4 | import isGroupRuleValid from '../utils/group-rules/isGroupRuleValid' 5 | import { useMemo } from 'react' 6 | import { Game } from '../entities/game' 7 | import { UnpackedGroupRule } from '../modals/groups/GroupDetails' 8 | import { PlayerGroupRuleMode } from '../entities/playerGroup' 9 | import makeValidatedGetRequest from './makeValidatedGetRequest' 10 | import { z } from 'zod' 11 | 12 | export default function useGroupPreviewCount(activeGame: Game, ruleMode: PlayerGroupRuleMode, rules: UnpackedGroupRule[]) { 13 | const fetcher = async ([url]: [string]) => { 14 | const qs = new URLSearchParams({ 15 | ruleMode, 16 | rules: JSON.stringify(rules.map(prepareRule)) 17 | }).toString() 18 | 19 | const res = await makeValidatedGetRequest(`${url}?${qs}`, z.object({ 20 | count: z.number() 21 | })) 22 | 23 | return res 24 | } 25 | 26 | const allValid = useMemo(() => { 27 | return rules.every(isGroupRuleValid) 28 | }, [rules]) 29 | 30 | const { data, error } = useSWR( 31 | activeGame && allValid ? [`games/${activeGame.id}/player-groups/preview-count`, ruleMode, JSON.stringify(rules)] : null, 32 | fetcher 33 | ) 34 | 35 | return { 36 | count: data?.count, 37 | loading: allValid && !data && !error, 38 | error: error && buildError(error) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/api/useGroupRules.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { availablePlayerGroupFieldSchema, playerGroupRuleOptionSchema } from '../entities/playerGroup' 7 | 8 | export default function useGroupRules(activeGame: Game) { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | availableRules: z.array(playerGroupRuleOptionSchema), 12 | availableFields: z.array(availablePlayerGroupFieldSchema) 13 | })) 14 | 15 | return res 16 | } 17 | 18 | const { data, error } = useSWR( 19 | [`games/${activeGame.id}/player-groups/rules`], 20 | fetcher 21 | ) 22 | 23 | return { 24 | availableRules: data?.availableRules ?? [], 25 | availableFields: data?.availableFields ?? [], 26 | loading: !data && !error, 27 | error: error && buildError(error) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/api/useGroups.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { playerGroupSchema } from '../entities/playerGroup' 6 | import { z } from 'zod' 7 | 8 | export default function useGroups(activeGame: Game) { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | groups: z.array(playerGroupSchema) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error, mutate } = useSWR( 18 | [`games/${activeGame.id}/player-groups`], 19 | fetcher 20 | ) 21 | 22 | return { 23 | groups: data?.groups ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error), 26 | mutate 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/api/useIntegrations.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { integrationSchema } from '../entities/integration' 7 | 8 | export default function useIntegrations(activeGame: Game) { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | integrations: z.array(integrationSchema) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error, mutate } = useSWR( 18 | [`/games/${activeGame.id}/integrations`], 19 | fetcher 20 | ) 21 | 22 | return { 23 | integrations: data?.integrations ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error), 26 | mutate 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/api/useLeaderboardEntries.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { leaderboardEntrySchema } from '../entities/leaderboardEntry' 7 | 8 | export default function useLeaderboardEntries(activeGame: Game, leaderboardId: number | undefined, page: number, withDeleted: boolean = false) { 9 | const fetcher = async ([url]: [string]) => { 10 | const params: Record = { page: String(page) } 11 | if (withDeleted) params.withDeleted = '1' 12 | const qs = new URLSearchParams(params).toString() 13 | 14 | const res = await makeValidatedGetRequest(`${url}?${qs}`, z.object({ 15 | entries: z.array(leaderboardEntrySchema), 16 | count: z.number(), 17 | itemsPerPage: z.number() 18 | })) 19 | 20 | return res 21 | } 22 | 23 | const { data, error, mutate } = useSWR( 24 | leaderboardId ? [`/games/${activeGame.id}/leaderboards/${leaderboardId}/entries`, page, withDeleted] : null, 25 | fetcher 26 | ) 27 | 28 | return { 29 | entries: data?.entries ?? [], 30 | count: data?.count, 31 | itemsPerPage: data?.itemsPerPage, 32 | loading: !data && !error, 33 | error: error && buildError(error), 34 | mutate 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/api/useLeaderboards.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { leaderboardSchema } from '../entities/leaderboard' 7 | 8 | export default function useLeaderboards(activeGame: Game) { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | leaderboards: z.array(leaderboardSchema) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error, mutate } = useSWR( 18 | [`/games/${activeGame.id}/leaderboards`], 19 | fetcher 20 | ) 21 | 22 | return { 23 | leaderboards: data?.leaderboards ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error), 26 | mutate 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/api/useOrganisation.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import makeValidatedGetRequest from './makeValidatedGetRequest' 4 | import { z } from 'zod' 5 | import { gameSchema } from '../entities/game' 6 | import { userSchema } from '../entities/user' 7 | import { inviteSchema } from '../entities/invite' 8 | 9 | export const currentOrganisationSchema = z.object({ 10 | games: z.array(gameSchema), 11 | members: z.array(userSchema), 12 | pendingInvites: z.array(inviteSchema) 13 | }) 14 | 15 | export default function useOrganisation() { 16 | const fetcher = async ([url]: [string]) => { 17 | const res = await makeValidatedGetRequest(url, currentOrganisationSchema) 18 | 19 | return res 20 | } 21 | 22 | const { data, error, mutate } = useSWR( 23 | ['/organisations/current'], 24 | fetcher 25 | ) 26 | 27 | return { 28 | games: data?.games ?? [], 29 | members: data?.members ?? [], 30 | pendingInvites: data?.pendingInvites ?? [], 31 | loading: !data && !error, 32 | error: error && buildError(error), 33 | mutate 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/api/useOrganisationPricingPlan.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import makeValidatedGetRequest from './makeValidatedGetRequest' 4 | import { z } from 'zod' 5 | import { organisationPricingPlanSchema } from '../entities/organisation' 6 | 7 | export default function useOrganisationPricingPlan() { 8 | const fetcher = async ([url]: [string]) => { 9 | const res = await makeValidatedGetRequest(url, z.object({ 10 | pricingPlan: organisationPricingPlanSchema 11 | })) 12 | 13 | return res 14 | } 15 | 16 | const { data, error } = useSWR( 17 | ['/billing/organisation-plan'], 18 | fetcher 19 | ) 20 | 21 | return { 22 | plan: data?.pricingPlan, 23 | loading: !data && !error, 24 | error: error && buildError(error) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/api/usePinnedGroups.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { playerGroupSchema } from '../entities/playerGroup' 6 | import { z } from 'zod' 7 | 8 | export default function usePinnedGroups(activeGame: Game | null, includeDevData?: boolean) { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | groups: z.array(playerGroupSchema) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error, mutate } = useSWR( 18 | activeGame ? [`games/${activeGame.id}/player-groups/pinned`, includeDevData] : null, 19 | fetcher 20 | ) 21 | 22 | return { 23 | groups: data?.groups ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error), 26 | mutate 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/api/usePlayerAuthActivities.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { playerAuthActivitySchema } from '../entities/playerAuthActivity' 7 | import canPerformAction, { PermissionBasedAction } from '../utils/canPerformAction' 8 | import { AuthedUser } from '../state/userState' 9 | 10 | export default function usePlayerAuthActivities(activeGame: Game, playerId: string, user: AuthedUser) { 11 | const fetcher = async ([url]: [string]) => { 12 | const res = await makeValidatedGetRequest(url, z.object({ 13 | activities: z.array(playerAuthActivitySchema) 14 | })) 15 | 16 | return res 17 | } 18 | 19 | const { data, error } = useSWR( 20 | canPerformAction(user, PermissionBasedAction.VIEW_PLAYER_AUTH_ACTIVITIES) && playerId 21 | ? [`/games/${activeGame.id}/players/${playerId}/auth-activities`] 22 | : null, 23 | fetcher 24 | ) 25 | 26 | return { 27 | activities: data?.activities ?? [], 28 | loading: !data && !error, 29 | error: error && buildError(error) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/api/usePlayerEvents.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { eventSchema } from '../entities/event' 6 | import { z } from 'zod' 7 | 8 | export default function usePlayerEvents(activeGame: Game, playerId: string, search: string, page: number) { 9 | const fetcher = async ([url]: [string]) => { 10 | const qs = new URLSearchParams({ search, page: String(page) }).toString() 11 | 12 | const res = await makeValidatedGetRequest(`${url}?${qs}`, z.object({ 13 | events: z.array(eventSchema), 14 | count: z.number(), 15 | itemsPerPage: z.number() 16 | })) 17 | 18 | return res 19 | } 20 | 21 | const { data, error } = useSWR( 22 | [`/games/${activeGame.id}/players/${playerId}/events`, search, page], 23 | fetcher 24 | ) 25 | 26 | return { 27 | events: data?.events ?? [], 28 | count: data?.count, 29 | itemsPerPage: data?.itemsPerPage, 30 | loading: !data && !error, 31 | error: error && buildError(error), 32 | errorStatusCode: error && error.response?.status 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/api/usePlayerHeadlines.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { PlayerHeadlines } from '../entities/playerHeadline' 7 | 8 | const defaultHeadlines: PlayerHeadlines = { 9 | total_players: { count: 0 }, 10 | online_players: { count: 0 } 11 | } 12 | 13 | export default function usePlayerHeadlines(activeGame: Game | null, includeDevData: boolean) { 14 | const fetcher = async ([url]: [string]) => { 15 | const headlines: (keyof PlayerHeadlines)[] = ['total_players', 'online_players'] 16 | const res = await Promise.all(headlines.map((headline) => makeValidatedGetRequest(`${url}/${headline}`, z.object({ 17 | count: z.number() 18 | })))) 19 | 20 | return headlines.reduce((acc, curr, idx) => ({ 21 | ...acc, 22 | [curr]: res[idx] 23 | }), defaultHeadlines) 24 | } 25 | 26 | const { data, error } = useSWR( 27 | activeGame ? [`/games/${activeGame.id}/headlines`, includeDevData] : null, 28 | fetcher 29 | ) 30 | 31 | return { 32 | headlines: data ?? defaultHeadlines, 33 | loading: !data && !error, 34 | error: error && buildError(error) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/api/usePlayerLeaderboardEntries.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import { Player } from '../entities/player' 5 | import { PlayerAlias } from '../entities/playerAlias' 6 | import { Leaderboard } from '../entities/leaderboard' 7 | import makeValidatedGetRequest from './makeValidatedGetRequest' 8 | import { z } from 'zod' 9 | import { leaderboardEntrySchema } from '../entities/leaderboardEntry' 10 | 11 | export default function usePlayerLeaderboardEntries(activeGame: Game, leaderboards: Leaderboard[], player: Player | null) { 12 | const fetcher = async ([activeGame, leaderboards, aliases]: [Game, Leaderboard[], PlayerAlias[]]) => { 13 | const urls = aliases.flatMap((alias) => { 14 | return leaderboards.map((leaderboard) => `/games/${activeGame.id}/leaderboards/${leaderboard.id}/entries?aliasId=${alias.id}&page=0`) 15 | }) 16 | 17 | const res = await Promise.all(urls.map((url) => makeValidatedGetRequest(url, z.object({ 18 | entries: z.array(leaderboardEntrySchema) 19 | })))) 20 | 21 | return { 22 | entries: res.flatMap((res) => res.entries) 23 | } 24 | } 25 | 26 | const { data, error, mutate } = useSWR( 27 | leaderboards && player ? [activeGame, leaderboards, player.aliases] : null, 28 | fetcher 29 | ) 30 | 31 | return { 32 | entries: data?.entries ?? [], 33 | loading: !data && !error, 34 | error: error && buildError(error), 35 | mutate 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/api/usePlayerSaves.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { gameSaveSchema } from '../entities/gameSave' 6 | import { z } from 'zod' 7 | 8 | export default function usePlayerSaves(activeGame: Game, playerId: string) { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | saves: z.array(gameSaveSchema) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error } = useSWR( 18 | [`/games/${activeGame.id}/players/${playerId}/saves`], 19 | fetcher 20 | ) 21 | 22 | return { 23 | saves: data?.saves ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error), 26 | errorStatusCode: error && error.response?.status 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/api/usePlayerStats.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { playerGameStatSchema } from '../entities/playerGameStat' 7 | 8 | const usePlayerStats = (activeGame: Game, playerId: string) => { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | stats: z.array(playerGameStatSchema) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error, mutate } = useSWR( 18 | [`/games/${activeGame.id}/players/${playerId}/stats`], 19 | fetcher 20 | ) 21 | 22 | return { 23 | stats: data?.stats ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error), 26 | errorStatusCode: error && error.response?.status, 27 | mutate 28 | } 29 | } 30 | 31 | export default usePlayerStats 32 | -------------------------------------------------------------------------------- /src/api/usePlayers.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import { z } from 'zod' 5 | import { playerSchema } from '../entities/player' 6 | import makeValidatedGetRequest from './makeValidatedGetRequest' 7 | 8 | export default function usePlayers(activeGame: Game, search: string, page: number) { 9 | const fetcher = async ([url, search, page]: [string, string, number]) => { 10 | const qs = new URLSearchParams({ 11 | search, 12 | page: String(page) 13 | }).toString() 14 | 15 | const res = await makeValidatedGetRequest(`${url}?${qs}`, z.object({ 16 | players: z.array(playerSchema), 17 | count: z.number(), 18 | itemsPerPage: z.number() 19 | })) 20 | 21 | return res 22 | } 23 | 24 | const { data, error } = useSWR( 25 | [`games/${activeGame.id}/players`, search, page], 26 | fetcher 27 | ) 28 | 29 | return { 30 | players: data?.players ?? [], 31 | count: data?.count, 32 | itemsPerPage: data?.itemsPerPage, 33 | loading: !data && !error, 34 | error: error && buildError(error) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/api/usePricingPlanUsage.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import makeValidatedGetRequest from './makeValidatedGetRequest' 4 | import { z } from 'zod' 5 | import { PricingPlanUsage } from '../entities/pricingPlan' 6 | 7 | export default function usePricingPlanUsage(run: boolean = true) { 8 | const fetcher = async ([url]: [string]) => { 9 | const res = await makeValidatedGetRequest(url, z.object({ 10 | usage: z.object({ 11 | limit: z.number().nullable(), 12 | used: z.number() 13 | }) 14 | })) 15 | 16 | return res 17 | } 18 | 19 | const { data, error } = useSWR( 20 | run ? ['/billing/usage'] : null, 21 | fetcher 22 | ) 23 | 24 | const limit = data?.usage.limit ?? (typeof data === 'undefined' ? 0 : Infinity) 25 | const used = data?.usage.used ?? 0 26 | 27 | return { 28 | usage: { limit, used } satisfies PricingPlanUsage, 29 | loading: !data && !error, 30 | error: error && buildError(error) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/api/usePricingPlans.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import makeValidatedGetRequest from './makeValidatedGetRequest' 4 | import { z } from 'zod' 5 | import { pricingPlanProductSchema } from '../entities/pricingPlan' 6 | 7 | export default function usePricingPlans() { 8 | const fetcher = async ([url]: [string]) => { 9 | const res = await makeValidatedGetRequest(url, z.object({ 10 | pricingPlans: z.array(pricingPlanProductSchema) 11 | })) 12 | 13 | return res 14 | } 15 | 16 | const { data, error } = useSWR( 17 | ['/billing/plans'], 18 | fetcher 19 | ) 20 | 21 | return { 22 | plans: data?.pricingPlans ?? [], 23 | loading: !data && !error, 24 | error: error && buildError(error) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/api/useStats.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import buildError from '../utils/buildError' 3 | import { Game } from '../entities/game' 4 | import makeValidatedGetRequest from './makeValidatedGetRequest' 5 | import { z } from 'zod' 6 | import { gameStatSchema } from '../entities/gameStat' 7 | 8 | export default function useStats(activeGame: Game | null, includeDevData?: boolean) { 9 | const fetcher = async ([url]: [string]) => { 10 | const res = await makeValidatedGetRequest(url, z.object({ 11 | stats: z.array(gameStatSchema) 12 | })) 13 | 14 | return res 15 | } 16 | 17 | const { data, error, mutate } = useSWR( 18 | activeGame ? [`/games/${activeGame.id}/game-stats`, includeDevData] : null, 19 | fetcher 20 | ) 21 | 22 | return { 23 | stats: data?.stats ?? [], 24 | loading: !data && !error, 25 | error: error && buildError(error), 26 | mutate 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/api/verify2FA.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | import { userSchema } from '../entities/user' 5 | 6 | const verify2FA = makeValidatedRequest( 7 | (code: string, userId: number) => api.post('/public/users/2fa', { code, userId }), 8 | z.object({ 9 | accessToken: z.string(), 10 | user: userSchema 11 | }) 12 | ) 13 | 14 | export default verify2FA 15 | -------------------------------------------------------------------------------- /src/api/viewRecoveryCodes.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import api from './api' 3 | import makeValidatedRequest from './makeValidatedRequest' 4 | 5 | const viewRecoveryCodes = makeValidatedRequest( 6 | (password: string) => api.post('/users/2fa/recovery_codes/view', { password }), 7 | z.object({ 8 | recoveryCodes: z.array(z.string()) 9 | }) 10 | ) 11 | 12 | export default viewRecoveryCodes 13 | -------------------------------------------------------------------------------- /src/assets/talo-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/talo-service.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/ActivityRenderer.tsx: -------------------------------------------------------------------------------- 1 | import SecondaryTitle from './SecondaryTitle' 2 | import { format } from 'date-fns' 3 | 4 | type ActivityRendererProps = { 5 | section: { 6 | date: Date 7 | items: { 8 | createdAt: string 9 | description: string 10 | extra?: Record 11 | }[] 12 | } 13 | } 14 | 15 | export default function ActivityRenderer({ section }: ActivityRendererProps) { 16 | return ( 17 |
18 | {format(section.date, 'dd MMM Y')} 19 | 20 | {section.items.map((item, itemIdx) => ( 21 |
22 |

{format(new Date(item.createdAt), 'HH:mm')} {item.description}

23 | 24 | {item.extra && 25 |
26 | {Object.keys(item.extra).sort((a, b) => { 27 | if (b === 'Player') return 1 28 | 29 | return a.localeCompare(b) 30 | }).map((key) => ( 31 | {key} = {String((item.extra ?? {})[key])} 32 | ))} 33 |
34 | } 35 |
36 | ))} 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/AlertBanner.tsx: -------------------------------------------------------------------------------- 1 | import type { Icon, IconProps } from '@tabler/icons-react' 2 | import { IconAlertCircle } from '@tabler/icons-react' 3 | import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react' 4 | import clsx from 'clsx' 5 | 6 | type AlertBannerIcon = ForwardRefExoticComponent & RefAttributes> 7 | 8 | type AlertBannerProps = { 9 | text: ReactNode | string 10 | className?: string 11 | icon?: AlertBannerIcon 12 | } 13 | 14 | export default function AlertBanner({ 15 | icon: Icon = IconAlertCircle, 16 | text, 17 | className 18 | }: AlertBannerProps) { 19 | return ( 20 |
21 | 22 | {text} 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { IconCheck } from '@tabler/icons-react' 3 | import { focusStyle } from '../styles/theme' 4 | import type { ReactNode, Ref } from 'react' 5 | 6 | type CheckboxProps = { 7 | id: string 8 | checked: boolean | undefined 9 | onChange: (checked: boolean) => void 10 | labelContent: ReactNode 11 | inputRef?: Ref 12 | } 13 | 14 | export default function Checkbox({ 15 | id, 16 | checked, 17 | onChange, 18 | labelContent, 19 | inputRef 20 | }: CheckboxProps) { 21 | return ( 22 |
23 | 24 | onChange(e.target.checked)} 31 | /> 32 | 33 | 34 | 35 | 36 | 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/CheckboxButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import clsx from 'clsx' 3 | import { IconCheck } from '@tabler/icons-react' 4 | import { hiddenInputStyle, labelFocusStyle } from '../styles/theme' 5 | 6 | type CheckboxButtonProps = { 7 | id: string 8 | checked: boolean 9 | onChange: (checked: boolean) => void 10 | label: string 11 | } 12 | 13 | export default function CheckboxButton({ id, checked, label, onChange }: CheckboxButtonProps) { 14 | const [focus, setFocus] = useState(false) 15 | 16 | return ( 17 | <> 18 | setFocus(true)} 23 | onBlur={() => setFocus(false)} 24 | onChange={() => onChange(!checked)} 25 | /> 26 | 27 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/DevDataStatus.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { useRecoilValue } from 'recoil' 3 | import devDataState from '../state/devDataState' 4 | import SecondaryTitle from './SecondaryTitle' 5 | import DevDataToggle from './toggles/DevDataToggle' 6 | 7 | function DevDataStatus() { 8 | const includeDevData = useRecoilValue(devDataState) 9 | 10 | return ( 11 |
12 | Dev data is currently 13 | 14 | {' '}{includeDevData ? 'enabled' : 'not enabled'} 15 | 16 | 17 | 18 |

When enabled, you'll see data submitted by players from dev builds of your game

19 | 20 | 21 |
22 | ) 23 | } 24 | 25 | export default DevDataStatus 26 | -------------------------------------------------------------------------------- /src/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { IconAlertCircle } from '@tabler/icons-react' 2 | import clsx from 'clsx' 3 | import type { ReactNode } from 'react' 4 | 5 | export type TaloError = { 6 | message: string 7 | keys?: { [key: string]: string[] } 8 | hasKeys?: boolean 9 | extra?: { [key: string]: string[] } 10 | } 11 | 12 | type ErrorMessageProps = { 13 | error: TaloError | null 14 | children?: ReactNode 15 | className?: string 16 | } 17 | 18 | function ErrorMessage({ 19 | error, 20 | children, 21 | className 22 | }: ErrorMessageProps) { 23 | const hasMultipleErrors = error?.hasKeys && Object.keys(error.keys!).length > 1 24 | 25 | return ( 26 |
27 |
28 | 29 |
30 | {error?.hasKeys && 31 | Object.entries(error.keys!).map(([key, value]) => ( 32 |

33 | {key}: {value.join(', ')} 34 |

35 | )) 36 | } 37 | 38 | {!error?.hasKeys && error?.message && 39 |

{error.message}

40 | } 41 | 42 | {children} 43 |
44 |
45 |
46 | ) 47 | } 48 | 49 | export default ErrorMessage 50 | -------------------------------------------------------------------------------- /src/components/HeadlineStat.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | 3 | type HeadlineStatProps = { 4 | title: string 5 | stat: string | number 6 | className?: string 7 | } 8 | 9 | export default function HeadlineStat({ className, title, stat }: HeadlineStatProps) { 10 | return ( 11 |
12 |
13 |

{title}

14 |
15 | 16 |
17 |

{stat.toLocaleString()}

18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Identifier.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | 3 | type IdentifierProps = { 4 | id: string 5 | children?: ReactNode 6 | } 7 | 8 | export default function Identifier({ 9 | id, 10 | children 11 | }: IdentifierProps) { 12 | return ( 13 |
14 | 15 | {children} 16 | {id} 17 | 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/IntendedRouteHandler.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { Navigate } from 'react-router-dom' 3 | import routes from '../constants/routes' 4 | 5 | type IntendedRouteHandlerProps = { 6 | intendedRoute: string | null 7 | } 8 | 9 | // 1. user hits dashboard.trytalo.com/leaderboards but is logged out 10 | // 2. user is redirected to dashboard.trytalo.com/?next=%2Fleaderboards 11 | // 3. "next" search param is put into sessionStorage 12 | // 4. useIntendedRoute hook picks up logic to redirect based on the sessionStorage 13 | export default function IntendedRouteHandler({ 14 | intendedRoute 15 | }: IntendedRouteHandlerProps) { 16 | useEffect(() => { 17 | const next = new URLSearchParams(window.location.search).get('next') 18 | if (next) { 19 | window.sessionStorage.setItem('intendedRoute', next) 20 | } 21 | }, []) 22 | 23 | if (!intendedRoute) { 24 | 25 | } 26 | 27 | return ( 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import { Link as RouterLink } from 'react-router-dom' 2 | import { focusStyle, linkStyle } from '../styles/theme' 3 | import clsx from 'clsx' 4 | import type { ReactNode } from 'react' 5 | 6 | type LinkProps = { 7 | to: string 8 | state?: object 9 | className?: string 10 | children: ReactNode 11 | } 12 | 13 | export default function Link({ 14 | to, 15 | state, 16 | className, 17 | children 18 | }: LinkProps) { 19 | const linkClass = clsx(linkStyle, focusStyle, className ?? '') 20 | 21 | if (to.startsWith('http')) { 22 | return ( 23 | {children} 24 | ) 25 | } 26 | 27 | return ( 28 | 33 | {children} 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/components/LinkButton.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent, ReactNode } from 'react' 2 | import { linkStyle, focusStyle } from '../styles/theme' 3 | import clsx from 'clsx' 4 | 5 | type LinkButtonProps = { 6 | onClick: (e: MouseEvent) => void 7 | className?: string 8 | children: ReactNode 9 | } 10 | 11 | export default function LinkButton({ onClick, className, children }: LinkButtonProps) { 12 | return ( 13 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | // Spinner from unmaintained package: https://github.com/adexin/spinners-react 2 | // 3 | import colors from 'tailwindcss/colors' 4 | 5 | type LoadingProps = { 6 | colour?: string 7 | size?: number 8 | thickness?: number 9 | } 10 | 11 | export default function Loading({ 12 | size = 80, 13 | thickness = 160 14 | }: LoadingProps) { 15 | const speed = 100 16 | const strokeWidth = 4 * (thickness / 100) 17 | 18 | return ( 19 | 20 | 21 | 29 | 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/MobileMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import clsx from 'clsx' 3 | import { IconArrowLeft } from '@tabler/icons-react' 4 | import Button from './Button' 5 | import { useLocation } from 'react-router-dom' 6 | import type { ReactNode } from 'react' 7 | 8 | type MobileMenuProps = { 9 | children: ReactNode 10 | visible: boolean 11 | onClose: () => void 12 | } 13 | 14 | export default function MobileMenu({ children, visible, onClose }: MobileMenuProps) { 15 | const location = useLocation() 16 | const [pathname, setPathname] = useState(location.pathname) 17 | 18 | useEffect(() => { 19 | setPathname(location.pathname) 20 | }, [visible, location]) 21 | 22 | useEffect(() => { 23 | if (visible && location.pathname !== pathname) { 24 | onClose() 25 | } 26 | }, [location.pathname, pathname, visible, onClose]) 27 | 28 | return ( 29 |
36 | 45 | 46 |
    47 | {children} 48 |
49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import Title from './Title' 3 | import Loading from './Loading' 4 | import GlobalBanners from './GlobalBanners' 5 | import type { ReactNode } from 'react' 6 | 7 | type PageProps = { 8 | title: string 9 | showBackButton?: boolean 10 | isLoading?: boolean 11 | containerClassName?: string 12 | children: ReactNode 13 | extraTitleComponent?: ReactNode 14 | secondaryNav?: ReactNode 15 | disableBanners?: boolean 16 | } 17 | 18 | export default function Page({ 19 | title, 20 | showBackButton = false, 21 | isLoading = false, 22 | containerClassName = '', 23 | extraTitleComponent, 24 | children, 25 | secondaryNav, 26 | disableBanners 27 | }: PageProps) { 28 | return ( 29 |
30 | {secondaryNav} 31 | 32 | {!disableBanners && } 33 | 34 |
35 |
36 | {title} 37 | 38 | {isLoading && 39 |
40 | 41 |
42 | } 43 | 44 | {!isLoading && extraTitleComponent} 45 |
46 | 47 | {children} 48 |
49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import Button from './Button' 3 | import { Dispatch, SetStateAction } from 'react' 4 | 5 | type PaginationProps = { 6 | count: number 7 | pageState: [number, Dispatch>] 8 | itemsPerPage: number 9 | } 10 | 11 | export default function Pagination({ 12 | count, 13 | pageState, 14 | itemsPerPage 15 | }: PaginationProps) { 16 | const totalPages = Math.ceil(count / itemsPerPage) 17 | 18 | if (totalPages === 1) return null 19 | 20 | const [page, setPage] = pageState 21 | const pages = [...new Array(totalPages)].map((_, idx) => String(idx + 1)) 22 | 23 | return ( 24 |
25 |
    26 | {pages.map((val, idx) => ( 27 |
  • 28 | 40 |
  • 41 | ))} 42 |
43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/PlayerIdentifier.tsx: -------------------------------------------------------------------------------- 1 | import Identifier from './Identifier' 2 | 3 | type PlayerIdentifierProps = { 4 | player?: { 5 | id: string 6 | devBuild: boolean 7 | } 8 | } 9 | 10 | function PlayerIdentifier({ 11 | player 12 | }: PlayerIdentifierProps) { 13 | return ( 14 | 15 | {player?.devBuild && DEV} 16 | 17 | ) 18 | } 19 | 20 | export default PlayerIdentifier 21 | -------------------------------------------------------------------------------- /src/components/SecondaryNav.tsx: -------------------------------------------------------------------------------- 1 | import Link from './Link' 2 | import clsx from 'clsx' 3 | import { useLocation } from 'react-router-dom' 4 | import canViewPage from '../utils/canViewPage' 5 | import userState from '../state/userState' 6 | import { useRecoilValue } from 'recoil' 7 | 8 | type SecondaryNavProps = { 9 | routes: { 10 | title: string 11 | to: string 12 | }[] 13 | } 14 | 15 | function SecondaryNav({ 16 | routes 17 | }: SecondaryNavProps) { 18 | const location = useLocation() 19 | const user = useRecoilValue(userState) 20 | 21 | const availableRoutes = routes.filter(({ to }) => canViewPage(user, to)) 22 | if (availableRoutes.length < 2) return null 23 | 24 | return ( 25 | 48 | ) 49 | } 50 | 51 | export default SecondaryNav 52 | -------------------------------------------------------------------------------- /src/components/SecondaryTitle.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import type { ReactNode } from 'react' 3 | 4 | type SecondaryTitleProps = { 5 | className?: string 6 | children: ReactNode 7 | } 8 | 9 | export default function SecondaryTitle({ 10 | className, 11 | children 12 | }: SecondaryTitleProps) { 13 | return

{children}

14 | } 15 | -------------------------------------------------------------------------------- /src/components/TaloInfoCard.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import LinkButton from './LinkButton' 3 | import clsx from 'clsx' 4 | import { IconChevronDown } from '@tabler/icons-react' 5 | import Link from './Link' 6 | 7 | export default function TaloInfoCard() { 8 | const [showInfo, setShowInfo] = useState(false) 9 | 10 | return ( 11 | <> 12 | setShowInfo(!showInfo)}> 13 | What is Talo? 14 | 15 | 16 | 17 | {showInfo && 18 |
19 |
20 |

21 | Talo is an open-source game backend: it's the easiest way to integrate leaderboards, stats, player management and more. 22 | {' '}Learn more here. 23 |

24 |
25 | } 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Tile.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import type { ReactNode } from 'react' 3 | 4 | type TileProps = { 5 | header: ReactNode 6 | content: ReactNode 7 | footer?: ReactNode 8 | selected?: boolean 9 | } 10 | 11 | export default function Tile({ 12 | header, 13 | content, 14 | footer, 15 | selected 16 | }: TileProps) { 17 | return ( 18 |
25 |
26 |
{header}
27 |
28 |
{content}
29 | 30 | {footer} 31 |
32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /src/components/TimePeriodPicker.tsx: -------------------------------------------------------------------------------- 1 | import { TimePeriod } from '../utils/useTimePeriod' 2 | import Button from './Button' 3 | import clsx from 'clsx' 4 | 5 | export type LabelledTimePeriod = { 6 | id: TimePeriod 7 | label: string 8 | } 9 | 10 | type TimePeriodPickerProps = { 11 | periods: LabelledTimePeriod[] 12 | onPick: (period: LabelledTimePeriod) => void 13 | selectedPeriod: TimePeriod | null 14 | } 15 | 16 | export default function TimePeriodPicker({ periods, onPick, selectedPeriod }: TimePeriodPickerProps) { 17 | const buttonClassName = 'text-sm md:text-base border-2 border-l-0 py-1 px-1 md:py-1.5 md:px-2 border-indigo-500 rounded-none first:rounded-l first:border-l-2 last:rounded-r hover:bg-gray-900 ring-inset' 18 | 19 | return ( 20 |
21 | {periods.map((period) => ( 22 | 30 | ))} 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Title.tsx: -------------------------------------------------------------------------------- 1 | import { IconArrowLeft } from '@tabler/icons-react' 2 | import Button from './Button' 3 | import { useNavigate } from 'react-router-dom' 4 | import clsx from 'clsx' 5 | import type { ReactNode } from 'react' 6 | 7 | type TitleProps = { 8 | children: ReactNode 9 | showBackButton?: boolean 10 | className?: string 11 | } 12 | 13 | function Title({ 14 | children, 15 | showBackButton, 16 | className 17 | }: TitleProps) { 18 | const navigate = useNavigate() 19 | 20 | return ( 21 |
22 | {showBackButton && 23 |
34 | ) 35 | } 36 | 37 | export default Title 38 | -------------------------------------------------------------------------------- /src/components/UsageWarningBanner.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom' 2 | import AlertBanner from './AlertBanner' 3 | import { IconInfoCircle } from '@tabler/icons-react' 4 | import { PricingPlanUsage } from '../entities/pricingPlan' 5 | 6 | type UsageWarningBannerProps = { 7 | usage: PricingPlanUsage 8 | } 9 | 10 | export default function UsageWarningBanner({ usage }: UsageWarningBannerProps) { 11 | return ( 12 | You've used {Math.round((usage.used / usage.limit) * 100)}% of your current plan's limit. Upgrade your plan to avoid any disruption to your game.

} 16 | /> 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/__tests__/DateInput.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event' 3 | import DateInput from '../DateInput' 4 | 5 | describe('', () => { 6 | beforeAll(() => { 7 | vi.setSystemTime(new Date(2022, 0, 1)) 8 | }) 9 | 10 | it('should the date as dd MMM yyyy', () => { 11 | render( 12 | 16 | ) 17 | 18 | expect(screen.getByDisplayValue('03 Mar 2022')).toBeInTheDocument() 19 | }) 20 | 21 | it('should render the default as today', () => { 22 | render( 23 | 27 | ) 28 | 29 | expect(screen.getByDisplayValue('01 Jan 2022')).toBeInTheDocument() 30 | }) 31 | 32 | it('should pick a date', async () => { 33 | const changeMock = vi.fn() 34 | 35 | render( 36 | changeMock(value)} 39 | value='' 40 | /> 41 | ) 42 | 43 | await userEvent.click(screen.getByDisplayValue('01 Jan 2022')) 44 | expect(await screen.findByText('January 2022')).toBeInTheDocument() 45 | await userEvent.click(screen.getByText('15'), { pointerEventsCheck: PointerEventsCheckLevel.Never }) 46 | expect(changeMock).toHaveBeenCalledWith('2022-01-15T00:00:00.000Z') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/components/__tests__/DevDataStatus.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import devDataState from '../../state/devDataState' 3 | import DevDataStatus from '../DevDataStatus' 4 | import KitchenSink from '../../utils/KitchenSink' 5 | 6 | describe('', () => { 7 | it('should render the not enabled state', () => { 8 | render( 9 | 10 | 11 | 12 | ) 13 | 14 | expect(screen.getByText('not enabled')).toBeInTheDocument() 15 | }) 16 | 17 | it('should render the enabled state', () => { 18 | render( 19 | 20 | 21 | 22 | ) 23 | 24 | expect(screen.queryByText('not enabled')).not.toBeInTheDocument() 25 | expect(screen.getByText('enabled')).toBeInTheDocument() 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/components/__tests__/ErrorMessage.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import ErrorMessage from '../ErrorMessage' 3 | import buildError from '../../utils/buildError' 4 | 5 | describe('', () => { 6 | it('should render all keys in the errors object', () => { 7 | render( 8 | 20 | ) 21 | 22 | expect(screen.getByText('startDate: The startDate is invalid')).toBeInTheDocument() 23 | expect(screen.getByText('endDate: The endDate is invalid')).toBeInTheDocument() 24 | }) 25 | 26 | it('should just render the message when there are no errors', () => { 27 | render( 28 | 37 | ) 38 | 39 | expect(screen.getByText('Something went wrong')).toBeInTheDocument() 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/components/__tests__/Link.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import routes from '../../constants/routes' 3 | import KitchenSink from '../../utils/KitchenSink' 4 | import Link from '../Link' 5 | 6 | describe('', () => { 7 | it('should render an internal router link', () => { 8 | render( 9 | 10 | Go to Account 11 | 12 | ) 13 | 14 | expect(screen.getByRole('link')).toBeInTheDocument() 15 | }) 16 | 17 | it('should render an external link', () => { 18 | render( 19 | 20 | See the docs 21 | 22 | ) 23 | 24 | const link = screen.getByRole('link') 25 | expect(link).toBeInTheDocument() 26 | expect(link).toHaveAttribute('rel', 'noreferrer') 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/components/__tests__/MobileMenu.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import { BrowserRouter } from 'react-router-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import MobileMenu from '../MobileMenu' 5 | import Link from '../Link' 6 | 7 | describe('', () => { 8 | it('should close when going to a different page', async () => { 9 | const closeMock = vi.fn() 10 | 11 | render( 12 | 13 |
  • 14 | Events 15 |
  • 16 |
    17 | , { wrapper: BrowserRouter } 18 | ) 19 | 20 | await userEvent.click(screen.getByText('Events')) 21 | 22 | await waitFor(() => expect(closeMock).toHaveBeenCalled()) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/components/__tests__/Modal.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import Modal from '../Modal' 4 | 5 | describe('', () => { 6 | it('should close when pressing escape', async () => { 7 | const closeMock = vi.fn() 8 | 9 | render( 10 | 15 | Content 16 | 17 | ) 18 | 19 | await userEvent.keyboard('{Escape}') 20 | 21 | expect(closeMock).toHaveBeenCalled() 22 | }) 23 | 24 | it('should close when clicking the close button', async () => { 25 | const closeMock = vi.fn() 26 | 27 | render( 28 | 33 | Content 34 | 35 | ) 36 | 37 | await userEvent.click(screen.getByLabelText('Close modal')) 38 | 39 | expect(closeMock).toHaveBeenCalled() 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/components/__tests__/Pagination.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import Pagination from '../Pagination' 4 | 5 | describe('', () => { 6 | it('should render the correct amount of pages', () => { 7 | render() 8 | expect(screen.getAllByRole('listitem')).toHaveLength(2) 9 | }) 10 | 11 | it('should go to the correct page on click', async () => { 12 | const changeMock = vi.fn() 13 | 14 | render() 15 | 16 | await userEvent.click(screen.getByText('4')) 17 | 18 | expect(changeMock).toHaveBeenCalledWith(3) 19 | }) 20 | 21 | it('should not render if there are not enough items to paginate', () => { 22 | render() 23 | 24 | expect(screen.queryByRole('listitem')).not.toBeInTheDocument() 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/components/__tests__/PlayerAliases.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import PlayerAliases from '../PlayerAliases' 3 | import { PlayerAlias, PlayerAliasService } from '../../entities/playerAlias' 4 | import playerAliasMock from '../../__mocks__/playerAliasMock' 5 | 6 | describe('', () => { 7 | it('should render an alias', () => { 8 | const aliases: PlayerAlias[] = [ 9 | playerAliasMock({ service: PlayerAliasService.STEAM, identifier: 'yxre12' }) 10 | ] 11 | 12 | render() 13 | 14 | expect(screen.getByText(aliases[0].identifier)).toBeInTheDocument() 15 | }) 16 | 17 | it('should render the latest alias and an indicator for how many more', () => { 18 | const aliases: PlayerAlias[] = [ 19 | playerAliasMock({ service: PlayerAliasService.STEAM, identifier: 'yxre12', lastSeenAt: '2024-10-28 10:00:00' }), 20 | playerAliasMock({ service: PlayerAliasService.USERNAME, identifier: 'ryet12', lastSeenAt: '2024-10-27 10:00:00' }), 21 | playerAliasMock({ service: PlayerAliasService.EPIC, identifier: 'epic_23rd', lastSeenAt: '2024-10-26 10:00:00' }) 22 | ] 23 | 24 | render() 25 | 26 | expect(screen.getByText(aliases[0].identifier)).toBeInTheDocument() 27 | expect(screen.getByText('+ 2 more')).toBeInTheDocument() 28 | }) 29 | 30 | it('should render none if the player has none', () => { 31 | const aliases: PlayerAlias[] = [] 32 | 33 | render() 34 | 35 | expect(screen.getByText('None')).toBeInTheDocument() 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/components/__tests__/PlayerIdentifier.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import PlayerIdentifier from '../PlayerIdentifier' 3 | 4 | describe('', () => { 5 | it('should render player identifiers for players not from dev build', () => { 6 | render() 7 | 8 | expect(screen.getByText('Player = 2a5adaba-fad2-471e-8ec1-47b9b24b5d3a')).toBeInTheDocument() 9 | expect(screen.queryByText('DEV')).not.toBeInTheDocument() 10 | }) 11 | 12 | it('should render player identifiers for players from dev build', () => { 13 | render() 14 | 15 | expect(screen.getByText('Player = f32be3f4-531c-4d28-97ab-1f7dc465cc65')).toBeInTheDocument() 16 | expect(screen.getByText('DEV')).toBeInTheDocument() 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/components/__tests__/SecondaryNav.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import userState from '../../state/userState' 3 | import SecondaryNav from '../SecondaryNav' 4 | import routes from '../../constants/routes' 5 | import KitchenSink from '../../utils/KitchenSink' 6 | import { UserType } from '../../entities/user' 7 | 8 | describe('', () => { 9 | it('should filter out pages that the user cannot see', () => { 10 | render( 11 | 12 | 20 | 21 | ) 22 | 23 | expect(screen.queryByText('Organisation')).not.toBeInTheDocument() 24 | }) 25 | 26 | it('should not render if there are less than 2 routes', () => { 27 | render( 28 | 29 | 34 | 35 | ) 36 | 37 | expect(screen.queryByText('Dashboard')).not.toBeInTheDocument() 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/components/__tests__/ServicesLink.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import ServicesLink from '../ServicesLink' 4 | import userState from '../../state/userState' 5 | import KitchenSink from '../../utils/KitchenSink' 6 | import routes from '../../constants/routes' 7 | import { UserType } from '../../entities/user' 8 | 9 | describe('', () => { 10 | it('should close when going to a different page', async () => { 11 | const setLocationMock = vi.fn() 12 | 13 | render( 14 | 18 | 19 | 20 | ) 21 | 22 | await userEvent.click(screen.getByText('Services')) 23 | await userEvent.click(screen.getByText('Players')) 24 | 25 | await waitFor(() => { 26 | expect(setLocationMock).toHaveBeenLastCalledWith({ 27 | pathname: routes.players, 28 | state: null 29 | }) 30 | }) 31 | 32 | await waitFor(() => expect(screen.queryByText('Players')).not.toBeInTheDocument()) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/components/__tests__/TimePeriodPicker.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import TimePeriodPicker, { LabelledTimePeriod } from '../TimePeriodPicker' 4 | 5 | describe('', () => { 6 | it('should render time periods', () => { 7 | const periods: LabelledTimePeriod[] = [{ id: '30d', label: '30 days' }, { id: '7d', label: '7 days' }] 8 | render() 9 | 10 | expect(screen.getAllByRole('button')).toHaveLength(2) 11 | 12 | for (const period of periods) { 13 | expect(screen.getByText(period.label)).toBeInTheDocument() 14 | } 15 | }) 16 | 17 | it('should pick the correct time periods', async () => { 18 | const pickMock = vi.fn() 19 | const periods: LabelledTimePeriod[] = [{ id: '30d', label: '30 days' }, { id: '7d', label: '7 days' }] 20 | render() 21 | 22 | await userEvent.click(screen.getByText(periods[1].label)) 23 | 24 | expect(pickMock).toHaveBeenCalledWith(periods[1]) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/components/__tests__/Title.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import KitchenSink from '../../utils/KitchenSink' 4 | import Title from '../Title' 5 | 6 | describe('', () => { 7 | it('should go back', async () => { 8 | const setLocationMock = vi.fn() 9 | 10 | render( 11 | <KitchenSink initialEntries={['/players', '/']} setLocation={setLocationMock}> 12 | <Title showBackButton>Player Props 13 | 14 | ) 15 | 16 | expect(screen.getByText('Player Props')).toBeInTheDocument() 17 | 18 | await userEvent.click(screen.getByLabelText('Go back')) 19 | 20 | await waitFor(() => { 21 | expect(setLocationMock).toHaveBeenCalledWith({ 22 | pathname: '/players', 23 | state: null 24 | }) 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/components/billing/BillingPortalTile.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import Button from '../Button' 3 | import buildError from '../../utils/buildError' 4 | import createPortalSession from '../../api/createPortalSession' 5 | import { IconExternalLink } from '@tabler/icons-react' 6 | import ErrorMessage, { TaloError } from '../ErrorMessage' 7 | import Tile from '../Tile' 8 | 9 | export default function BillingPortalTile() { 10 | const [portalLoading, setPortalLoading] = useState(false) 11 | const [portalError, setPortalError] = useState(null) 12 | 13 | const onBillingPortalClick = async () => { 14 | setPortalLoading(true) 15 | setPortalError(null) 16 | 17 | try { 18 | const { redirect } = await createPortalSession() 19 | window.location.assign(redirect) 20 | } catch (err) { 21 | setPortalLoading(false) 22 | setPortalError(buildError(err)) 23 | } 24 | } 25 | 26 | return ( 27 |
  • 28 | 31 |

    Billing details

    32 | 41 | 42 | )} 43 | content={( 44 |
    45 |

    You can update your billing information and view invoices inside the billing portal

    46 |
    47 | )} 48 | footer={( 49 | <> 50 | {portalError && 51 |
    52 | } 53 | 54 | )} 55 | /> 56 |
  • 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/components/billing/BillingUsageTile.tsx: -------------------------------------------------------------------------------- 1 | import Tile from '../Tile' 2 | import ErrorMessage, { TaloError } from '../ErrorMessage' 3 | import clsx from 'clsx' 4 | import { PricingPlanUsage } from '../../entities/pricingPlan' 5 | 6 | type BillingUsageTileProps = { 7 | usage: PricingPlanUsage 8 | usageError: TaloError | null 9 | } 10 | 11 | export default function BillingUsageTile({ 12 | usage, 13 | usageError 14 | }: BillingUsageTileProps) { 15 | return ( 16 |
  • 17 | Usage} 19 | content={ 20 | <> 21 | {!usageError && 22 |
      23 |
    • 24 |

      Players

      25 |

      = usage.limit })} 28 | > 29 | {usage.used.toLocaleString()} / {usage.limit.toLocaleString()} 30 |

      31 | 32 |
      33 |
      = usage.limit })} 35 | style={{ width: `${Math.min(100, usage.used / usage.limit * 100)}%` }} 36 | /> 37 |
      38 |
    • 39 |
    40 | } 41 | 42 | } 43 | footer={( 44 | <> 45 | {usageError && 46 |
    47 | } 48 | 49 | )} 50 | /> 51 |
  • 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/billing/PaymentRequiredBanner.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useState } from 'react' 2 | import buildError from '../../utils/buildError' 3 | import Button from '../Button' 4 | import ErrorMessage, { TaloError } from '../ErrorMessage' 5 | import createPortalSession from '../../api/createPortalSession' 6 | import { IconAlertCircle } from '@tabler/icons-react' 7 | 8 | export default function PaymentRequiredBanner() { 9 | const [isLoading, setLoading] = useState(false) 10 | const [error, setError] = useState(null) 11 | 12 | const onUpdateClick = async (e: MouseEvent) => { 13 | e.preventDefault() 14 | 15 | setLoading(true) 16 | try { 17 | const { redirect } = await createPortalSession() 18 | window.location.assign(redirect) 19 | } catch (err) { 20 | setError(buildError(err)) 21 | } finally { 22 | setLoading(false) 23 | } 24 | } 25 | 26 | return ( 27 |
    28 |
    29 |

    30 | 31 | Payment failed 32 |

    33 |

    Please update your payment details to continue your current price plan

    34 |
    35 | 36 |
    37 |
    38 | 45 |
    46 |
    47 | 48 | {error && } 49 |
    50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/billing/RegisterPlanBanner.tsx: -------------------------------------------------------------------------------- 1 | import { IconInfoCircle } from '@tabler/icons-react' 2 | import AlertBanner from '../AlertBanner' 3 | import { capitalize } from 'lodash-es' 4 | 5 | export default function RegisterPlanBanner() { 6 | const planName = new URLSearchParams(window.location.search).get('plan') 7 | 8 | if (!planName || planName.toLowerCase() === 'indie') return null 9 | 10 | const text = planName.toLowerCase() === 'enterprise' 11 | ? To learn more about Enterprise Plans, visit the Billing page after registering and creating your first game 12 | : To upgrade to the {capitalize(planName)} Plan, visit the Billing page after registering and creating your first game 13 | 14 | return ( 15 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/billing/__tests__/BillingPortalTile.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import api from '../../../api/api' 3 | import MockAdapter from 'axios-mock-adapter' 4 | import BillingPortalTile from '../BillingPortalTile' 5 | import userEvent from '@testing-library/user-event' 6 | 7 | describe('', () => { 8 | const axiosMock = new MockAdapter(api) 9 | 10 | it('should create a portal session and redirect', async () => { 11 | axiosMock.onPost('http://talo.api/billing/portal-session').replyOnce(200, { 12 | redirect: 'http://stripe.com/portal' 13 | }) 14 | 15 | const assignMock = vi.fn() 16 | Object.defineProperty(window, 'location', { 17 | value: { assign: assignMock } 18 | }) 19 | 20 | render() 21 | 22 | await userEvent.click(screen.getByText('Billing Portal')) 23 | 24 | await waitFor(() => { 25 | expect(assignMock).toHaveBeenCalledWith('http://stripe.com/portal') 26 | }) 27 | }) 28 | 29 | it('should render errors', async () => { 30 | axiosMock.onPost('http://talo.api/billing/portal-session').networkErrorOnce() 31 | 32 | render() 33 | 34 | await userEvent.click(screen.getByText('Billing Portal')) 35 | 36 | expect(await screen.findByRole('alert')).toHaveTextContent('Network Error') 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/components/billing/__tests__/BillingUsageTile.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import BillingUsageTile from '../BillingUsageTile' 3 | import KitchenSink from '../../../utils/KitchenSink' 4 | import buildError from '../../../utils/buildError' 5 | 6 | describe('', () => { 7 | it('should render player usages', () => { 8 | render( 9 | 10 | 17 | 18 | ) 19 | 20 | expect(screen.getByText('Players')).toBeInTheDocument() 21 | expect(screen.getByText('3 / 5')).toBeInTheDocument() 22 | }) 23 | 24 | it('should handle usage errors', async () => { 25 | render( 26 | 27 | 28 | 29 | ) 30 | 31 | expect(await screen.findByText('Network Error')).toHaveAttribute('role', 'alert') 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/components/billing/__tests__/PaymentRequiredBanner.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor, within } from '@testing-library/react' 2 | import api from '../../../api/api' 3 | import MockAdapter from 'axios-mock-adapter' 4 | import userEvent from '@testing-library/user-event' 5 | import PaymentRequiredBanner from '../PaymentRequiredBanner' 6 | 7 | describe('', () => { 8 | const axiosMock = new MockAdapter(api) 9 | 10 | it('should create a portal session and redirect', async () => { 11 | axiosMock.onPost('http://talo.api/billing/portal-session').replyOnce(200, { 12 | redirect: 'http://stripe.com/portal' 13 | }) 14 | 15 | const assignMock = vi.fn() 16 | Object.defineProperty(window, 'location', { 17 | value: { assign: assignMock } 18 | }) 19 | 20 | render() 21 | 22 | await userEvent.click(screen.getByText('Update details')) 23 | 24 | await waitFor(() => { 25 | expect(assignMock).toHaveBeenCalledWith('http://stripe.com/portal') 26 | }) 27 | }) 28 | 29 | it('should render errors', async () => { 30 | axiosMock.onPost('http://talo.api/billing/portal-session').networkErrorOnce() 31 | 32 | render() 33 | 34 | await userEvent.click(screen.getByText('Update details')) 35 | 36 | const content = screen.getByTestId('banner-content') 37 | expect(await within(content).findByRole('alert')).toHaveTextContent('Network Error') 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/components/billing/__tests__/RegisterPlanBanner.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import RegisterPlanBanner from '../RegisterPlanBanner' 3 | 4 | describe('', () => { 5 | it('should not render anything if there is no plan query param', () => { 6 | Object.defineProperty(window, 'location', { 7 | value: { search: '' } 8 | }) 9 | 10 | render() 11 | 12 | expect(screen.queryByTestId('alert-banner')).not.toBeInTheDocument() 13 | }) 14 | 15 | it('should not render anything if the plan is the free (indie) plan', () => { 16 | Object.defineProperty(window, 'location', { 17 | value: { search: '?plan=indie' } 18 | }) 19 | 20 | render() 21 | 22 | expect(screen.queryByTestId('alert-banner')).not.toBeInTheDocument() 23 | }) 24 | 25 | it('should render enterprise plan copy if the plan is enterprise', () => { 26 | Object.defineProperty(window, 'location', { 27 | value: { search: '?plan=enterprise' } 28 | }) 29 | 30 | render() 31 | 32 | expect(screen.getByText(/To learn more about/)).toBeInTheDocument() 33 | expect(screen.getByText(/Enterprise Plan/)).toBeInTheDocument() 34 | }) 35 | 36 | it('should render enterprise any other plan copy if the plan is not enterprise', () => { 37 | Object.defineProperty(window, 'location', { 38 | value: { search: '?plan=team' } 39 | }) 40 | 41 | render() 42 | 43 | expect(screen.getByText(/To upgrade to the/)).toBeInTheDocument() 44 | expect(screen.getByText(/Team Plan/)).toBeInTheDocument() 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/components/charts/ChartTick.tsx: -------------------------------------------------------------------------------- 1 | type ChartTickProps = { 2 | x?: number 3 | y?: number 4 | payload?: { value: string | number } 5 | formatter: (value: string | number) => string | number 6 | transform: (x?: number, y?: number) => string 7 | } 8 | 9 | export default function ChartTick({ x, y, payload, formatter, transform }: ChartTickProps) { 10 | return ( 11 | 12 | 13 | {payload && formatter(payload.value)} 14 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/charts/__tests__/ChartTick.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import ChartTick from '../ChartTick' 3 | 4 | describe('', () => { 5 | it('should correctly format labels', () => { 6 | render( 7 | 8 | (tick as string).split(' ')[0]} 11 | transform={vi.fn()} 12 | /> 13 | 14 | ) 15 | 16 | expect(screen.getByText('1995-11-12')).toBeInTheDocument() 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/components/saves/SaveContentFitManager.tsx: -------------------------------------------------------------------------------- 1 | import { useReactFlow } from '@xyflow/react' 2 | import { useEffect } from 'react' 3 | import { useRecoilValue } from 'recoil' 4 | import saveDataNodeSizesState from '../../state/saveDataNodeSizesState' 5 | import { useDebounce } from 'use-debounce' 6 | 7 | export default function SaveContentFitManager() { 8 | const reactFlow = useReactFlow() 9 | const nodeSizes = useRecoilValue(saveDataNodeSizesState) 10 | 11 | const [debouncedLength] = useDebounce(nodeSizes.length, 100) 12 | 13 | useEffect(() => { 14 | if (debouncedLength > 0) { 15 | reactFlow.fitView() 16 | } 17 | }, [debouncedLength, reactFlow]) 18 | 19 | return null 20 | } 21 | -------------------------------------------------------------------------------- /src/components/tables/Table.tsx: -------------------------------------------------------------------------------- 1 | import TableHeader from './TableHeader' 2 | import type { ReactNode } from 'react' 3 | 4 | type TableProps = { 5 | columns: string[] 6 | children: ReactNode 7 | } 8 | 9 | export default function Table({ 10 | columns, 11 | children 12 | }: TableProps) { 13 | return ( 14 |
    15 | 16 | 17 | {children} 18 |
    19 |
    20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/tables/TableBody.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import type { ReactNode } from 'react' 3 | 4 | type TableBodyProps = { 5 | iterator: T[] 6 | children: (iteraee: T, idx: number) => ReactNode 7 | startIdx?: number 8 | configureClassnames?: (iteraee: T, idx: number) => { [key: string]: boolean } 9 | } 10 | 11 | export default function TableBody({ 12 | iterator, 13 | children, 14 | startIdx = 0, 15 | configureClassnames 16 | }: TableBodyProps) { 17 | return ( 18 | 19 | {iterator.map((iteraee, idx) => ( 20 | 28 | {children(iteraee, idx)} 29 | 30 | ))} 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/components/tables/TableCell.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import type { ReactNode } from 'react' 3 | 4 | type TableCellProps = { 5 | children?: ReactNode 6 | className?: string 7 | } 8 | 9 | const TableCell = (props: TableCellProps) => { 10 | return ( 11 | 20 | {props.children} 21 | 22 | ) 23 | } 24 | 25 | export default TableCell 26 | -------------------------------------------------------------------------------- /src/components/tables/TableHeader.tsx: -------------------------------------------------------------------------------- 1 | type TableHeaderProps = { 2 | columns: string[] 3 | } 4 | 5 | const TableHeader = (props: TableHeaderProps) => { 6 | return ( 7 | 8 | 9 | {props.columns.map((col, idx) => ( 10 | {col} 11 | ))} 12 | 13 | 14 | ) 15 | } 16 | 17 | export default TableHeader 18 | -------------------------------------------------------------------------------- /src/components/tables/cells/DateCell.tsx: -------------------------------------------------------------------------------- 1 | import TableCell from '../TableCell' 2 | import type { ReactNode } from 'react' 3 | 4 | type DateCellProps = { 5 | children: ReactNode 6 | } 7 | 8 | const DateCell = (props: DateCellProps) => { 9 | return ( 10 | 11 | {props.children} 12 | 13 | ) 14 | } 15 | 16 | export default DateCell 17 | -------------------------------------------------------------------------------- /src/components/toast/ToastContext.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export enum ToastType { 4 | NONE = '', 5 | SUCCESS = 'success', 6 | ERROR = 'error' 7 | } 8 | 9 | export type ToastContextType = { 10 | trigger: (text: string, type?: ToastType) => void 11 | } 12 | 13 | export default React.createContext({ trigger: () => {} }) 14 | -------------------------------------------------------------------------------- /src/components/toast/ToastProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitForElementToBeRemoved } from '@testing-library/react' 2 | import { useContext, useRef } from 'react' 3 | import ToastContext from './ToastContext' 4 | import ToastProvider from './ToastProvider' 5 | import userEvent from '@testing-library/user-event' 6 | 7 | function ToastDummy() { 8 | const { trigger } = useContext(ToastContext) 9 | return 10 | } 11 | 12 | function MultiToastDummy() { 13 | const { trigger } = useContext(ToastContext) 14 | const clicks = useRef(0) 15 | 16 | return ( 17 | 26 | ) 27 | } 28 | 29 | describe('', () => { 30 | it('should trigger then hide a toast', async () => { 31 | render( 32 | 33 | 34 | 35 | ) 36 | 37 | await userEvent.click(screen.getByText('Trigger')) 38 | expect(await screen.findByText('Hello!')).toBeInTheDocument() 39 | }) 40 | 41 | it('should trigger multiple toasts', async () => { 42 | render( 43 | 44 | 45 | 46 | ) 47 | 48 | await userEvent.click(screen.getByText('Trigger')) 49 | expect(await screen.findByText('Hello!')).toBeInTheDocument() 50 | 51 | await userEvent.click(screen.getByText('Trigger')) 52 | await waitForElementToBeRemoved(() => screen.queryByText('Hello!')) 53 | expect(await screen.findByText('Hello again!')).toBeInTheDocument() 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/components/toggles/__tests__/DevDataToggle.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import KitchenSink from '../../../utils/KitchenSink' 4 | import devDataState from '../../../state/devDataState' 5 | import DevDataToggle from '../DevDataToggle' 6 | 7 | describe('', () => { 8 | it('should correctly toggle between states', async () => { 9 | const toggleMock = vi.fn() 10 | 11 | render( 12 | 13 | 14 | 15 | ) 16 | 17 | await userEvent.click(screen.getByRole('checkbox')) 18 | await waitFor(() => expect(toggleMock).toHaveBeenLastCalledWith(true)) 19 | 20 | await userEvent.click(screen.getByRole('checkbox')) 21 | await waitFor(() => expect(toggleMock).toHaveBeenLastCalledWith(false)) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/toggles/__tests__/Toggle.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import Toggle from '../Toggle' 4 | 5 | describe('', () => { 6 | it('should correctly toggle between states', async () => { 7 | const toggleMock = vi.fn() 8 | render() 9 | 10 | const { click } = userEvent.setup() 11 | 12 | await click(screen.getByRole('checkbox')) 13 | await waitFor(() => expect(screen.getByRole('checkbox')).toBeChecked()) 14 | expect(toggleMock).toHaveBeenLastCalledWith(true) 15 | 16 | await click(screen.getByRole('checkbox')) 17 | await waitFor(() => expect(screen.getByRole('checkbox')).not.toBeChecked()) 18 | expect(toggleMock).toHaveBeenLastCalledWith(false) 19 | }) 20 | 21 | it('should correctly render the disabled state', () => { 22 | render() 23 | 24 | expect(screen.getByRole('checkbox')).toBeDisabled() 25 | expect(screen.getByRole('checkbox')).not.toBeChecked() 26 | }) 27 | 28 | it('should correctly render a toggled disabled state', async () => { 29 | render() 30 | 31 | expect(screen.getByRole('checkbox')).toBeDisabled() 32 | await waitFor(() => expect(screen.getByRole('checkbox')).toBeChecked()) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | acceptInvite: '/accept/:token', 3 | account: '/account', 4 | activity: '/activity', 5 | apiKeys: '/api-keys', 6 | billing: '/billing', 7 | channels: '/channels', 8 | confirmPassword: '/confirm-password', 9 | dashboard: '/', 10 | dataExports: '/exports', 11 | demo: '/demo', 12 | eventsOverview: '/events', 13 | eventBreakdown: '/events/breakdown', 14 | feedback: '/feedback', 15 | feedbackCategories: '/feedback-categories', 16 | forgotPassword: '/forgot-password', 17 | gameProps: '/game-config', 18 | gameSettings: '/game-settings', 19 | groups: '/groups', 20 | integrations: '/integrations', 21 | leaderboards: '/leaderboards', 22 | leaderboardEntries: '/leaderboards/:internalName', 23 | login: '/', 24 | organisation: '/organisation', 25 | players: '/players', 26 | playerEvents: '/players/:id/events', 27 | playerLeaderboardEntries: '/players/:id/leaderboard-entries', 28 | playerProfile: '/players/:id/profile', 29 | playerProps: '/players/:id/props', 30 | playerSaves: '/players/:id/saves', 31 | playerSaveContent: '/players/:id/saves/:saveId', 32 | playerStats: '/players/:id/stats', 33 | recover: '/recover', 34 | register: '/register', 35 | resetPassword: '/reset-password', 36 | stats: '/stats', 37 | verify2FA: '/verify' 38 | } 39 | -------------------------------------------------------------------------------- /src/constants/secondaryNavRoutes.ts: -------------------------------------------------------------------------------- 1 | import routes from './routes' 2 | 3 | export const secondaryNavRoutes = [ 4 | { title: 'Dashboard', to: routes.dashboard }, 5 | { title: 'Activity log', to: routes.activity }, 6 | { title: 'Game settings', to: routes.gameSettings }, 7 | { title: 'Organisation', to: routes.organisation }, 8 | { title: 'Billing', to: routes.billing } 9 | ] 10 | -------------------------------------------------------------------------------- /src/constants/userTypeMap.ts: -------------------------------------------------------------------------------- 1 | import { UserType } from '../entities/user' 2 | 3 | const userTypeMap = { 4 | [UserType.OWNER]: 'Owner', 5 | [UserType.ADMIN]: 'Admin', 6 | [UserType.DEV]: 'Dev', 7 | [UserType.DEMO]: 'Demo' 8 | } 9 | 10 | export default userTypeMap 11 | -------------------------------------------------------------------------------- /src/entities/apiKey.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const apiKeySchema = z.object({ 4 | id: z.number(), 5 | scopes: z.array(z.string()), 6 | gameId: z.number(), 7 | createdBy: z.string(), 8 | createdAt: z.string().datetime(), 9 | lastUsedAt: z.string().datetime().nullish() 10 | }) 11 | 12 | export type APIKey = z.infer 13 | -------------------------------------------------------------------------------- /src/entities/dataExport.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export enum DataExportStatus { 4 | REQUESTED, 5 | QUEUED, 6 | GENERATED, 7 | SENT 8 | } 9 | 10 | export enum DataExportAvailableEntities { 11 | EVENTS = 'events', 12 | PLAYERS = 'players', 13 | PLAYER_ALIASES = 'playerAliases', 14 | LEADERBOARD_ENTRIES = 'leaderboardEntries', 15 | GAME_STATS = 'gameStats', 16 | PLAYER_GAME_STATS = 'playerGameStats', 17 | GAME_ACTIVITIES = 'gameActivities', 18 | GAME_FEEDBACK = 'gameFeedback' 19 | } 20 | 21 | export const dataExportSchema = z.object({ 22 | id: z.number(), 23 | entities: z.array(z.nativeEnum(DataExportAvailableEntities)), 24 | createdBy: z.string(), 25 | status: z.nativeEnum(DataExportStatus), 26 | createdAt: z.string().datetime(), 27 | failedAt: z.string().datetime().nullish() 28 | }) 29 | 30 | export type DataExport = z.infer 31 | -------------------------------------------------------------------------------- /src/entities/event.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { propSchema } from './prop' 3 | import { playerAliasSchema } from './playerAlias' 4 | 5 | export const eventSchema = z.object({ 6 | id: z.string().uuid(), 7 | name: z.string(), 8 | props: z.array(propSchema), 9 | playerAlias: playerAliasSchema, 10 | gameId: z.number(), 11 | createdAt: z.string().datetime() 12 | }) 13 | 14 | export type Event = z.infer 15 | -------------------------------------------------------------------------------- /src/entities/game.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { propSchema } from './prop' 3 | 4 | export const gameSchema = z.object({ 5 | id: z.number(), 6 | name: z.string(), 7 | props: z.array(propSchema), 8 | playerCount: z.number().optional(), 9 | createdAt: z.string().datetime() 10 | }) 11 | 12 | export type Game = z.infer 13 | -------------------------------------------------------------------------------- /src/entities/gameActivity.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const gameActivitySchema = z.object({ 4 | id: z.number(), 5 | type: z.number(), 6 | description: z.string(), 7 | extra: z.record(z.unknown()).optional(), 8 | createdAt: z.string().datetime() 9 | }) 10 | 11 | export type GameActivity = z.infer 12 | -------------------------------------------------------------------------------- /src/entities/gameChannels.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { propSchema } from './prop' 3 | import { playerAliasSchema } from './playerAlias' 4 | 5 | export const gameChannelSchema = z.object({ 6 | id: z.number(), 7 | name: z.string(), 8 | owner: z.union([ 9 | playerAliasSchema, 10 | z.null() 11 | ]), 12 | totalMessages: z.number(), 13 | props: z.array(propSchema), 14 | memberCount: z.number(), 15 | autoCleanup: z.boolean(), 16 | private: z.boolean(), 17 | temporaryMembership: z.boolean(), 18 | createdAt: z.string().datetime(), 19 | updatedAt: z.string().datetime() 20 | }) 21 | 22 | export type GameChannel = z.infer 23 | -------------------------------------------------------------------------------- /src/entities/gameFeedback.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { playerAliasSchema } from './playerAlias' 3 | import { gameFeedbackCategorySchema } from './gameFeedbackCategory' 4 | 5 | export const gameFeedbackSchema = z.object({ 6 | id: z.number(), 7 | category: gameFeedbackCategorySchema, 8 | comment: z.string(), 9 | anonymised: z.boolean(), 10 | playerAlias: playerAliasSchema.nullable(), 11 | devBuild: z.boolean(), 12 | createdAt: z.string().datetime() 13 | }) 14 | 15 | export type GameFeedback = z.infer 16 | -------------------------------------------------------------------------------- /src/entities/gameFeedbackCategory.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const gameFeedbackCategorySchema = z.object({ 4 | id: z.number(), 5 | internalName: z.string(), 6 | name: z.string(), 7 | description: z.string(), 8 | anonymised: z.boolean(), 9 | createdAt: z.string().datetime(), 10 | updatedAt: z.string().datetime() 11 | }) 12 | 13 | export type GameFeedbackCategory = z.infer 14 | -------------------------------------------------------------------------------- /src/entities/gameSave.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const gameSaveSchema = z.object({ 4 | id: z.number(), 5 | name: z.string(), 6 | content: z.record(z.string(), z.unknown()), 7 | createdAt: z.string().datetime(), 8 | updatedAt: z.string().datetime() 9 | }) 10 | 11 | export type GameSave = z.infer 12 | -------------------------------------------------------------------------------- /src/entities/gameStat.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const gameStatSchema = z.object({ 4 | id: z.number(), 5 | internalName: z.string(), 6 | name: z.string(), 7 | global: z.boolean(), 8 | globalValue: z.number(), 9 | defaultValue: z.number(), 10 | maxChange: z.number().nullable(), 11 | minValue: z.number().nullable(), 12 | maxValue: z.number().nullable(), 13 | minTimeBetweenUpdates: z.number(), 14 | createdAt: z.string().datetime(), 15 | updatedAt: z.string().datetime() 16 | }) 17 | 18 | export type GameStat = z.infer 19 | -------------------------------------------------------------------------------- /src/entities/headline.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const countSchema = z.object({ 4 | count: z.number() 5 | }) 6 | 7 | export const averageSessionDurationSchema = z.object({ 8 | hours: z.number(), 9 | minutes: z.number(), 10 | seconds: z.number() 11 | }) 12 | 13 | export const headlinesSchema = z.object({ 14 | new_players: countSchema, 15 | returning_players: countSchema, 16 | events: countSchema, 17 | unique_event_submitters: countSchema, 18 | total_sessions: countSchema, 19 | average_session_duration: averageSessionDurationSchema 20 | }) 21 | 22 | export type Headlines = z.infer 23 | -------------------------------------------------------------------------------- /src/entities/integration.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export enum IntegrationType { 4 | STEAMWORKS = 'steamworks' 5 | } 6 | 7 | export const steamIntegrationConfigSchema = z.object({ 8 | appId: z.number(), 9 | syncLeaderboards: z.boolean(), 10 | syncStats: z.boolean() 11 | }) 12 | 13 | export type SteamIntegrationConfig = z.infer 14 | 15 | // use union when more integrations are added 16 | export const integrationConfigSchema = steamIntegrationConfigSchema 17 | 18 | export type IntegrationConfig = z.infer 19 | 20 | export const integrationSchema = z.object({ 21 | id: z.number(), 22 | type: z.nativeEnum(IntegrationType), 23 | config: integrationConfigSchema, 24 | createdAt: z.string().datetime(), 25 | updatedAt: z.string().datetime() 26 | }) 27 | 28 | export type Integration = z.infer 29 | -------------------------------------------------------------------------------- /src/entities/invite.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { UserType } from './user' 3 | import { organisationSchema } from './organisation' 4 | 5 | export const inviteSchema = z.object({ 6 | id: z.number(), 7 | email: z.string().email(), 8 | organisation: organisationSchema, 9 | type: z.nativeEnum(UserType), 10 | invitedBy: z.string(), 11 | createdAt: z.string().datetime() 12 | }) 13 | 14 | export type Invite = z.infer 15 | -------------------------------------------------------------------------------- /src/entities/invoice.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const invoiceSchema = z.object({ 4 | lines: z.array(z.object({ 5 | id: z.string(), 6 | amount: z.number(), 7 | description: z.string().nullable(), 8 | period: z.object({ 9 | start: z.number(), 10 | end: z.number() 11 | }) 12 | })), 13 | total: z.number(), 14 | collectionDate: z.number(), 15 | prorationDate: z.number() 16 | }) 17 | 18 | export type Invoice = z.infer 19 | -------------------------------------------------------------------------------- /src/entities/leaderboard.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export enum LeaderboardSortMode { 4 | DESC = 'desc', 5 | ASC = 'asc' 6 | } 7 | 8 | export enum LeaderboardRefreshInterval { 9 | NEVER = 'never', 10 | DAILY = 'daily', 11 | WEEKLY = 'weekly', 12 | MONTHLY = 'monthly', 13 | YEARLY = 'yearly' 14 | } 15 | 16 | export const leaderboardSchema = z.object({ 17 | id: z.number(), 18 | internalName: z.string(), 19 | name: z.string(), 20 | sortMode: z.nativeEnum(LeaderboardSortMode), 21 | unique: z.boolean(), 22 | refreshInterval: z.nativeEnum(LeaderboardRefreshInterval), 23 | createdAt: z.string().datetime(), 24 | updatedAt: z.string().datetime() 25 | }) 26 | 27 | export type Leaderboard = z.infer 28 | -------------------------------------------------------------------------------- /src/entities/leaderboardEntry.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { playerAliasSchema } from './playerAlias' 3 | import { propSchema } from './prop' 4 | 5 | export const leaderboardEntrySchema = z.object({ 6 | id: z.number(), 7 | position: z.number().optional(), 8 | score: z.number(), 9 | leaderboardName: z.string(), 10 | leaderboardInternalName: z.string(), 11 | playerAlias: playerAliasSchema, 12 | hidden: z.boolean(), 13 | props: z.array(propSchema), 14 | createdAt: z.string().datetime(), 15 | updatedAt: z.string().datetime(), 16 | deletedAt: z.string().datetime().nullable() 17 | }) 18 | 19 | export type LeaderboardEntry = z.infer 20 | -------------------------------------------------------------------------------- /src/entities/organisation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { gameSchema } from './game' 3 | import { pricingPlanSchema } from './pricingPlan' 4 | 5 | export const statusSchema = z.enum(['active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'paused', 'trialing', 'unpaid']) 6 | 7 | export const organisationPricingPlanSchema = z.object({ 8 | pricingPlan: pricingPlanSchema, 9 | status: statusSchema, 10 | endDate: z.string().datetime().nullable(), 11 | canViewBillingPortal: z.boolean() 12 | }) 13 | 14 | export const organisationSchema = z.object({ 15 | id: z.number(), 16 | name: z.string(), 17 | games: z.array(gameSchema), 18 | pricingPlan: z.object({ 19 | status: statusSchema 20 | }) 21 | }) 22 | 23 | export type Organisation = z.infer 24 | -------------------------------------------------------------------------------- /src/entities/player.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { propSchema } from './prop' 3 | import { playerAliasSchema, PlayerAlias } from './playerAlias' 4 | import { playerPresenceSchema } from './playerPresence' 5 | 6 | export const basePlayerSchema = z.object({ 7 | id: z.string().uuid(), 8 | props: z.array(propSchema), 9 | devBuild: z.boolean(), 10 | createdAt: z.string().datetime(), 11 | lastSeenAt: z.string().datetime(), 12 | groups: z.array(z.object({ 13 | id: z.string(), 14 | name: z.string() 15 | })), 16 | presence: playerPresenceSchema.nullable() 17 | }) 18 | 19 | export type Player = z.infer & { 20 | aliases: PlayerAlias[] 21 | } 22 | 23 | export const playerSchema: z.ZodType = basePlayerSchema.extend({ 24 | aliases: z.lazy(() => playerAliasSchema.array()) 25 | }) 26 | -------------------------------------------------------------------------------- /src/entities/playerAlias.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { basePlayerSchema } from './player' 3 | 4 | export enum PlayerAliasService { 5 | STEAM = 'steam', 6 | EPIC = 'epic', 7 | USERNAME = 'username', 8 | EMAIL = 'email', 9 | CUSTOM = 'custom', 10 | TALO = 'talo' 11 | } 12 | 13 | export const playerAliasSchema = z.object({ 14 | id: z.number(), 15 | service: z.string(), 16 | identifier: z.string(), 17 | player: z.lazy(() => basePlayerSchema), 18 | lastSeenAt: z.string().datetime(), 19 | createdAt: z.string().datetime(), 20 | updatedAt: z.string().datetime() 21 | }) 22 | 23 | export type PlayerAlias = z.infer 24 | -------------------------------------------------------------------------------- /src/entities/playerAuthActivity.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const playerAuthActivitySchema = z.object({ 4 | id: z.number(), 5 | type: z.number(), 6 | description: z.string(), 7 | extra: z.record(z.unknown()), 8 | createdAt: z.string().datetime() 9 | }) 10 | 11 | export type PlayerAuthActivity = z.infer 12 | -------------------------------------------------------------------------------- /src/entities/playerGameStat.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { gameStatSchema } from './gameStat' 3 | 4 | export const playerGameStatSchema = z.object({ 5 | id: z.number(), 6 | stat: gameStatSchema, 7 | value: z.number(), 8 | createdAt: z.string().datetime(), 9 | updatedAt: z.string().datetime() 10 | }) 11 | 12 | export type PlayerGameStat = z.infer 13 | -------------------------------------------------------------------------------- /src/entities/playerHeadline.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const countSchema = z.object({ 4 | count: z.number() 5 | }) 6 | 7 | export const playerHeadlinesSchema = z.object({ 8 | total_players: countSchema, 9 | online_players: countSchema 10 | }) 11 | 12 | export type PlayerHeadlines = z.infer 13 | -------------------------------------------------------------------------------- /src/entities/playerPresence.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const playerPresenceSchema = z.object({ 4 | online: z.boolean(), 5 | customStatus: z.string(), 6 | updatedAt: z.string().datetime() 7 | }) 8 | 9 | export type PlayerPresence = z.infer 10 | -------------------------------------------------------------------------------- /src/entities/pricingPlan.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const pricingPlanSchema = z.object({ 4 | id: z.number(), 5 | stripeId: z.string(), 6 | hidden: z.boolean(), 7 | default: z.boolean(), 8 | playerLimit: z.number().nullable() 9 | }) 10 | 11 | export const pricingPlanUsageSchema = z.object({ 12 | limit: z.number(), 13 | used: z.number() 14 | }) 15 | 16 | export type PricingPlanUsage = z.infer 17 | 18 | const pricingPlanProductPriceSchema = z.object({ 19 | currency: z.string(), 20 | amount: z.number(), 21 | interval: z.enum(['day', 'week', 'month', 'year']), 22 | current: z.boolean() 23 | }) 24 | 25 | export type PricingPlanProductPrice = z.infer 26 | 27 | export const pricingPlanProductSchema = pricingPlanSchema.merge( 28 | z.object({ 29 | name: z.string(), 30 | prices: z.array( 31 | z.object({ 32 | currency: z.string(), 33 | amount: z.number(), 34 | interval: z.enum(['day', 'week', 'month', 'year']), 35 | current: z.boolean() 36 | }) 37 | ) 38 | }) 39 | ) 40 | 41 | export type PricingPlanProduct = z.infer 42 | -------------------------------------------------------------------------------- /src/entities/prop.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const propSchema = z.object({ 4 | key: z.string(), 5 | value: z.string().nullable() 6 | }) 7 | 8 | export type Prop = z.infer 9 | -------------------------------------------------------------------------------- /src/entities/user.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { organisationSchema } from './organisation' 3 | 4 | export enum UserType { 5 | OWNER, 6 | ADMIN, 7 | DEV, 8 | DEMO 9 | } 10 | 11 | export const userSchema = z.object({ 12 | id: z.number(), 13 | email: z.string().email(), 14 | username: z.string(), 15 | lastSeenAt: z.string().datetime(), 16 | emailConfirmed: z.boolean(), 17 | organisation: organisationSchema, 18 | type: z.nativeEnum(UserType), 19 | has2fa: z.boolean(), 20 | createdAt: z.string().datetime() 21 | }) 22 | 23 | export type User = z.infer 24 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './styles/index.css' 4 | import 'tippy.js/dist/tippy.css' 5 | import 'react-day-picker/dist/style.css' 6 | import '@xyflow/react/dist/base.css' 7 | import App from './App' 8 | import { RecoilRoot } from 'recoil' 9 | import { BrowserRouter } from 'react-router-dom' 10 | import * as Sentry from '@sentry/react' 11 | import ToastProvider from './components/toast/ToastProvider' 12 | 13 | if (import.meta.env.VITE_SENTRY_DSN?.startsWith('http')) { 14 | Sentry.init({ 15 | dsn: import.meta.env.VITE_SENTRY_DSN, 16 | environment: import.meta.env.VITE_SENTRY_ENV 17 | }) 18 | } 19 | 20 | const root = createRoot(document.getElementById('root')!) 21 | root.render( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | -------------------------------------------------------------------------------- /src/pages/Activity.tsx: -------------------------------------------------------------------------------- 1 | import useGameActivities from '../api/useGameActivities' 2 | import ErrorMessage from '../components/ErrorMessage' 3 | import activeGameState, { SelectedActiveGame } from '../state/activeGameState' 4 | import { useRecoilValue } from 'recoil' 5 | import SecondaryNav from '../components/SecondaryNav' 6 | import { secondaryNavRoutes } from '../constants/secondaryNavRoutes' 7 | import Page from '../components/Page' 8 | import useDaySections from '../utils/useDaySections' 9 | import ActivityRenderer from '../components/ActivityRenderer' 10 | 11 | export default function Activity() { 12 | const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame 13 | const { activities, loading, error } = useGameActivities(activeGame) 14 | const sections = useDaySections(activities) 15 | 16 | return ( 17 | } 21 | > 22 | {!error && !loading && sections.length === 0 && 23 |

    {activeGame.name} doesn't have any activity yet

    24 | } 25 | 26 | {!error && sections.map((section, sectionIdx) => ( 27 | 28 | ))} 29 | 30 | {error && } 31 |
    32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/pages/EventsOverview.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilValue } from 'recoil' 2 | import activeGameState, { SelectedActiveGame } from '../state/activeGameState' 3 | import Page from '../components/Page' 4 | import EventsDisplay from '../components/events/EventsDisplay' 5 | import EventsFiltersSection from '../components/events/EventsFiltersSection' 6 | import { EventsProvider, useEventsContext } from '../components/events/EventsContext' 7 | import useEvents from '../api/useEvents' 8 | 9 | const localStorageKey = 'eventsOverview' 10 | 11 | export default function EventsOverview() { 12 | const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame 13 | 14 | return ( 15 | 16 | 17 | 18 | ) 19 | } 20 | 21 | function EventsOverviewDisplay({ activeGame }: { activeGame: SelectedActiveGame }) { 22 | const { debouncedStartDate, debouncedEndDate } = useEventsContext() 23 | const { events, eventNames, loading, error } = useEvents(activeGame, debouncedStartDate, debouncedEndDate) 24 | 25 | return ( 26 | 27 | 28 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/GameProps.tsx: -------------------------------------------------------------------------------- 1 | import Page from '../components/Page' 2 | import activeGameState, { SelectedActiveGameState } from '../state/activeGameState' 3 | import { useRecoilState } from 'recoil' 4 | import PropsEditor from '../components/PropsEditor' 5 | import updateGame from '../api/updateGame' 6 | import { Prop } from '../entities/prop' 7 | 8 | export default function GameProps() { 9 | const [activeGame, setActiveGame] = useRecoilState(activeGameState) as SelectedActiveGameState 10 | 11 | const onSave = async (props: Prop[]): Promise => { 12 | const { game } = await updateGame(activeGame.id, { props }) 13 | setActiveGame(game) 14 | return game.props 15 | } 16 | 17 | return ( 18 | 22 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import Button from '../components/Button' 2 | import { unauthedContainerStyle } from '../styles/theme' 3 | import { useNavigate } from 'react-router-dom' 4 | import routes from '../constants/routes' 5 | import { useEffect, useState } from 'react' 6 | import Loading from '../components/Loading' 7 | import clsx from 'clsx' 8 | 9 | export default function NotFound() { 10 | const navigate = useNavigate() 11 | 12 | const [routeChecked, setRouteChcked] = useState(false) 13 | 14 | useEffect(() => { 15 | // e.g. /demo doesn't exist post-auth but it was a real route pre-auth 16 | if (Object.values(routes).includes(window.location.pathname)) { 17 | navigate(routes.dashboard) 18 | } else { 19 | setRouteChcked(true) 20 | } 21 | }, [navigate]) 22 | 23 | if (!routeChecked) { 24 | return 25 | } 26 | 27 | return ( 28 |
    29 |
    30 |

    404 Not Found

    31 |

    32 | Sorry, we couldn't find that page. If this is a mistake, please contact us. 33 |

    34 | 35 | 40 |
    41 |
    42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/PlayerProps.tsx: -------------------------------------------------------------------------------- 1 | import Page from '../components/Page' 2 | import updatePlayer from '../api/updatePlayer' 3 | import Loading from '../components/Loading' 4 | import PlayerIdentifier from '../components/PlayerIdentifier' 5 | import usePlayer from '../utils/usePlayer' 6 | import activeGameState, { SelectedActiveGame } from '../state/activeGameState' 7 | import { useRecoilValue } from 'recoil' 8 | import PropsEditor from '../components/PropsEditor' 9 | import { Prop } from '../entities/prop' 10 | 11 | export default function PlayerProps() { 12 | const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame 13 | 14 | const [player] = usePlayer() 15 | 16 | const onSave = async (props: Prop[]): Promise => { 17 | const { player: updatedPlayer } = await updatePlayer(activeGame.id, player.id, { props }) 18 | return updatedPlayer.props 19 | } 20 | 21 | if (!player) { 22 | return ( 23 |
    24 | 25 |
    26 | ) 27 | } 28 | 29 | return ( 30 | 35 | 36 | 37 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/services/AuthService.ts: -------------------------------------------------------------------------------- 1 | const AuthService = (() => { 2 | let _token: string | null = null 3 | 4 | const getToken = () => { 5 | return _token 6 | } 7 | 8 | const setToken = (token: string) => { 9 | _token = token 10 | } 11 | 12 | const reload = () => { 13 | window.localStorage.setItem('loggedOut', 'true') 14 | window.location.reload() 15 | } 16 | 17 | return { 18 | getToken, 19 | setToken, 20 | reload 21 | } 22 | })() 23 | 24 | export default AuthService 25 | -------------------------------------------------------------------------------- /src/services/__tests__/AuthService.test.ts: -------------------------------------------------------------------------------- 1 | import AuthService from '../AuthService' 2 | 3 | describe('AuthService', () => { 4 | it('should set the token', () => { 5 | AuthService.setToken('abc123') 6 | expect(AuthService.getToken()).toBe('abc123') 7 | }) 8 | 9 | it('should set the loggedOut key after reloading', () => { 10 | const reloadMock = vi.fn() 11 | 12 | const { reload } = window.location 13 | Object.defineProperty(window, 'location', { 14 | value: { reload: reloadMock } 15 | }) 16 | 17 | AuthService.reload() 18 | 19 | expect(window.localStorage.getItem('loggedOut')).toBe('true') 20 | expect(reloadMock).toHaveBeenCalled() 21 | 22 | window.location.reload = reload 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/state/RecoilObserver.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { RecoilState, useRecoilState } from 'recoil' 3 | 4 | type RecoilObserverProps = { 5 | node: RecoilState 6 | onChange?: (value: T) => void 7 | } 8 | 9 | export default function RecoilObserver({ node, onChange }: RecoilObserverProps) { 10 | const [value] = useRecoilState(node) 11 | 12 | useEffect(() => { 13 | onChange?.(value) 14 | }, [onChange, value]) 15 | 16 | return null 17 | } 18 | -------------------------------------------------------------------------------- /src/state/activeGameState.ts: -------------------------------------------------------------------------------- 1 | import { SetterOrUpdater, atom } from 'recoil' 2 | import { Game } from '../entities/game' 3 | 4 | const activeGameState = atom({ 5 | key: 'activeGame', 6 | default: JSON.parse(window.localStorage.getItem('activeGame') ?? 'null'), 7 | effects: [ 8 | ({ onSet }) => { 9 | onSet((updatedActiveGame) => { 10 | window.localStorage.setItem('activeGame', JSON.stringify(updatedActiveGame)) 11 | }) 12 | } 13 | ] 14 | }) 15 | 16 | export type SelectedActiveGame = NonNullable 17 | 18 | export type SelectedActiveGameState = [SelectedActiveGame, SetterOrUpdater] 19 | 20 | export default activeGameState 21 | -------------------------------------------------------------------------------- /src/state/devDataState.ts: -------------------------------------------------------------------------------- 1 | import { AtomEffect, atom } from 'recoil' 2 | 3 | const localStorageEffect: AtomEffect = ({ setSelf, onSet }) => { 4 | if (!window.localStorage.getItem(key)) { 5 | setSelf(true) 6 | window.localStorage.setItem(key, 'true') 7 | } 8 | 9 | onSet((includeDevData: boolean) => { 10 | window.localStorage.setItem(key, String(includeDevData)) 11 | }) 12 | } 13 | 14 | const key = 'includeDevData' 15 | const devDataState = atom({ 16 | key, 17 | default: window.localStorage.getItem(key) === 'true', 18 | effects: [localStorageEffect] 19 | }) 20 | 21 | export default devDataState 22 | -------------------------------------------------------------------------------- /src/state/gamesState.ts: -------------------------------------------------------------------------------- 1 | import { selector } from 'recoil' 2 | import userState from './userState' 3 | import { Game } from '../entities/game' 4 | 5 | const gamesState = selector({ 6 | key: 'games', 7 | get: ({ get }) => { 8 | const user = get(userState) 9 | return user?.organisation.games ?? [] 10 | } 11 | }) 12 | 13 | export default gamesState 14 | -------------------------------------------------------------------------------- /src/state/justConfirmedEmailState.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil' 2 | 3 | const justConfirmedEmailState = atom({ 4 | key: 'justConfirmedEmail', 5 | default: false 6 | }) 7 | 8 | export default justConfirmedEmailState 9 | -------------------------------------------------------------------------------- /src/state/organisationState.ts: -------------------------------------------------------------------------------- 1 | import { selector } from 'recoil' 2 | import userState, { AuthedUser } from './userState' 3 | import { Organisation } from '../entities/organisation' 4 | 5 | const organisationState = selector({ 6 | key: 'organisation', 7 | get: ({ get }) => { 8 | const user = get(userState) as AuthedUser 9 | return user.organisation 10 | } 11 | }) 12 | 13 | export default organisationState 14 | -------------------------------------------------------------------------------- /src/state/saveDataNodeSizesState.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil' 2 | 3 | export type SaveDataNodeSize = { 4 | id: string 5 | width: number 6 | height: number 7 | } 8 | 9 | const saveDataNodeSizesState = atom({ 10 | key: 'saveDataNodeSizes', 11 | default: [] 12 | }) 13 | 14 | export default saveDataNodeSizesState 15 | -------------------------------------------------------------------------------- /src/state/userState.ts: -------------------------------------------------------------------------------- 1 | import { SetterOrUpdater, atom } from 'recoil' 2 | import { User } from '../entities/user' 3 | 4 | const userState = atom({ 5 | key: 'user', 6 | default: null 7 | }) 8 | 9 | export type AuthedUser = NonNullable 10 | 11 | export type AuthedUserState = [AuthedUser, SetterOrUpdater] 12 | 13 | export default userState 14 | -------------------------------------------------------------------------------- /src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | export const focusStyle = 'focus:outline-none focus:ring focus:ring-pink-500 focus:z-[999]' 2 | 3 | export const labelFocusStyle = 'ring ring-pink-500' 4 | 5 | export const linkStyle = 'text-indigo-400 hover:underline font-semibold transition-colors rounded-none' 6 | 7 | export const unauthedContainerStyle = 'w-full md:w-2/3 xl:w-1/3' 8 | 9 | export const hiddenInputStyle = 'absolute inset-0 opacity-0 w-0 h-0' 10 | -------------------------------------------------------------------------------- /src/utils/__tests__/canPerformAction.test.ts: -------------------------------------------------------------------------------- 1 | import userMock from '../../__mocks__/userMock' 2 | import { UserType } from '../../entities/user' 3 | import canPerformAction, { PermissionBasedAction } from '../canPerformAction' 4 | 5 | describe('canPerformAction', () => { 6 | it('should always return true for owner users', () => { 7 | const user = userMock({ type: UserType.OWNER }) 8 | expect(canPerformAction(user, PermissionBasedAction.DELETE_LEADERBOARD)).toBe(true) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /src/utils/buildError.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | import { TaloError } from '../components/ErrorMessage' 3 | 4 | type APIError = { 5 | message: string 6 | errors: { [key: string]: string[] } 7 | } 8 | 9 | export default function buildError(error: unknown, preferredKey: string = ''): TaloError { 10 | let message = '' 11 | let keys: { [key: string]: string[] } = {} 12 | let extra: Partial> = {} 13 | 14 | if (typeof error === 'string') { 15 | message = error 16 | } else { 17 | const axiosError = error as AxiosError 18 | 19 | if (axiosError.response?.data?.message) { 20 | message = axiosError.response.data.message 21 | extra = (Object.keys(axiosError.response.data) as (keyof APIError)[]) 22 | .reduce((acc, key) => { 23 | if (key !== 'message') { 24 | return { ...acc, [key]: axiosError.response!.data[key] } 25 | } 26 | return acc 27 | }, {} as Partial>) 28 | } else if (axiosError.response?.data?.errors) { 29 | keys = axiosError.response.data.errors 30 | extra = (Object.keys(axiosError.response.data) as (keyof APIError)[]) 31 | .reduce((acc, key) => { 32 | if (key !== 'errors') { 33 | return { ...acc, [key]: axiosError.response!.data[key] } 34 | } 35 | return acc 36 | }, {} as Partial>) 37 | 38 | message = Object.keys(keys).includes(preferredKey) 39 | ? keys[preferredKey][0] 40 | : 'Something went wrong, please try again later' 41 | } else { 42 | message = axiosError.message 43 | } 44 | } 45 | 46 | return { 47 | message, 48 | keys, 49 | hasKeys: Object.keys(keys).length > 0, 50 | extra 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/canPerformAction.ts: -------------------------------------------------------------------------------- 1 | import { User, UserType } from '../entities/user' 2 | 3 | export enum PermissionBasedAction { 4 | DELETE_LEADERBOARD, 5 | DELETE_STAT, 6 | DELETE_GROUP, 7 | DELETE_FEEDBACK_CATEGORY, 8 | VIEW_PLAYER_AUTH_ACTIVITIES, 9 | UPDATE_PLAYER_STAT, 10 | UPDATE_LEADERBOARD_ENTRY, 11 | DELETE_CHANNEL 12 | } 13 | 14 | export default function canPerformAction(user: User, action: PermissionBasedAction) { 15 | if (user.type === UserType.OWNER) return true 16 | 17 | switch (action) { 18 | case PermissionBasedAction.DELETE_LEADERBOARD: 19 | case PermissionBasedAction.DELETE_STAT: 20 | case PermissionBasedAction.DELETE_FEEDBACK_CATEGORY: 21 | case PermissionBasedAction.VIEW_PLAYER_AUTH_ACTIVITIES: 22 | case PermissionBasedAction.UPDATE_PLAYER_STAT: 23 | case PermissionBasedAction.UPDATE_LEADERBOARD_ENTRY: 24 | case PermissionBasedAction.DELETE_CHANNEL: 25 | return user.type === UserType.ADMIN 26 | case PermissionBasedAction.DELETE_GROUP: 27 | return [UserType.DEV, UserType.ADMIN].includes(user.type) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/canViewPage.ts: -------------------------------------------------------------------------------- 1 | import routes from '../constants/routes' 2 | import { User, UserType } from '../entities/user' 3 | 4 | export default function canViewPage(user: User | null, route: string) { 5 | if (!user) return false 6 | if (user.type === UserType.OWNER) return true 7 | 8 | switch (route) { 9 | case routes.activity: 10 | case routes.integrations: 11 | case routes.gameProps: 12 | return [UserType.ADMIN, UserType.DEMO].includes(user.type) 13 | case routes.apiKeys: 14 | return user.type === UserType.ADMIN 15 | case routes.dataExports: 16 | return user.type === UserType.ADMIN && user.emailConfirmed 17 | case routes.organisation: 18 | return user.type === UserType.ADMIN 19 | case routes.billing: 20 | case routes.gameSettings: 21 | return false 22 | case routes.feedbackCategories: 23 | return [UserType.ADMIN, UserType.DEV].includes(user.type) 24 | default: 25 | return true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/getEventColour.ts: -------------------------------------------------------------------------------- 1 | import randomColor from 'randomcolor' 2 | 3 | export default (eventName: string) => { 4 | return randomColor({ seed: eventName }) 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/group-rules/isGroupRuleValid.ts: -------------------------------------------------------------------------------- 1 | import { UnpackedGroupRule } from '../../modals/groups/GroupDetails' 2 | 3 | export default function isGroupRuleValid(rawRule: UnpackedGroupRule) { 4 | if (Object.keys(rawRule.operands).length < rawRule.operandCount) return false 5 | if (Object.values(rawRule.operands).some((operand) => (operand ?? '').length === 0)) return false 6 | if (rawRule.namespaced && !rawRule.namespacedValue) return false 7 | return true 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/group-rules/prepareRule.ts: -------------------------------------------------------------------------------- 1 | import { PlayerGroupRule } from '../../entities/playerGroup' 2 | import { UnpackedGroupRule } from '../../modals/groups/GroupDetails' 3 | 4 | export default function prepareRule(rule: UnpackedGroupRule): PlayerGroupRule { 5 | return { 6 | name: rule.name, 7 | negate: rule.negate, 8 | castType: rule.castType, 9 | field: rule.namespaced ? `${rule.mapsTo}.${rule.namespacedValue}` : rule.mapsTo, 10 | operands: Object.values(rule.operands).filter((_, idx) => idx < rule.operandCount), 11 | namespaced: rule.namespaced 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/group-rules/unpackRules.ts: -------------------------------------------------------------------------------- 1 | import { PlayerGroupRule } from '../../entities/playerGroup' 2 | import { UnpackedGroupRule } from '../../modals/groups/GroupDetails' 3 | 4 | export function unpackRules(rules?: PlayerGroupRule[]): UnpackedGroupRule[] | undefined { 5 | if (!rules) return rules 6 | 7 | return rules.map((rule) => { 8 | const field = rule.field.split('.')[0] 9 | const namespacedValue = rule.namespaced 10 | ? rule.field.split('.').slice(1).join('.') 11 | : '' 12 | 13 | return { 14 | name: rule.name, 15 | negate: rule.negate, 16 | castType: rule.castType, 17 | mapsTo: field, 18 | namespaced: rule.namespaced, 19 | namespacedValue, 20 | operands: rule.operands.reduce((acc, curr, idx) => ({ 21 | ...acc, 22 | [idx]: curr 23 | }), {}), 24 | operandCount: rule.operands.length 25 | } 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/useDaySections.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import useSortedItems from './useSortedItems' 3 | import { differenceInDays, isSameDay, startOfDay, subDays } from 'date-fns' 4 | 5 | export default function useDaySections(items: T[]) { 6 | const sortedItems = useSortedItems(items, 'createdAt') 7 | 8 | const sections = useMemo(() => { 9 | if (sortedItems.length === 0) return [] 10 | const latestDate = sortedItems[0].createdAt 11 | const oldestDate = sortedItems[sortedItems.length - 1].createdAt 12 | 13 | const numSections = differenceInDays(new Date(latestDate), new Date(oldestDate)) + 1 14 | 15 | const sections = [] 16 | for (let i = 0; i < numSections; i++) { 17 | const date = startOfDay(subDays(new Date(latestDate), i)) 18 | sections.push({ 19 | date, 20 | items: sortedItems.filter((item) => isSameDay(date, new Date(item.createdAt))) 21 | }) 22 | } 23 | 24 | return sections.filter((section) => section.items.length > 0) 25 | }, [sortedItems]) 26 | 27 | return sections 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/useIntendedRoute.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react' 2 | import { useNavigate } from 'react-router-dom' 3 | 4 | export default function useIntendedRoute() { 5 | const navigate = useNavigate() 6 | 7 | const intendedRouteChecked = useCallback(() => { 8 | return window.sessionStorage.getItem('intendedRouteChecked') === 'true' 9 | }, []) 10 | 11 | const setIntendedRouteChecked = useCallback(() => { 12 | window.sessionStorage.setItem('intendedRouteChecked', 'true') 13 | }, []) 14 | 15 | useEffect(() => { 16 | const intended = window.sessionStorage.getItem('intendedRoute') 17 | 18 | if (intended) { 19 | window.sessionStorage.removeItem('intendedRoute') 20 | navigate(intended, { replace: true }) 21 | } else { 22 | setIntendedRouteChecked() 23 | } 24 | }, [navigate, setIntendedRouteChecked]) 25 | 26 | return intendedRouteChecked() 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | // https://usehooks.com/useLocalStorage 2 | 3 | import { useCallback, useState } from 'react' 4 | 5 | export type SetLocalStorageValue = (value: T | ((curr: T) => T)) => void 6 | 7 | export default function useLocalStorage(key: string, initialValue: T): [T, SetLocalStorageValue] { 8 | // State to store our value 9 | // Pass initial state function to useState so logic is only executed once 10 | const [storedValue, setStoredValue] = useState(() => { 11 | try { 12 | // Get from local storage by key 13 | const item = window.localStorage.getItem(key) 14 | // Parse stored json or if none return initialValue 15 | return item ? JSON.parse(item) as T : initialValue 16 | } catch (error) { 17 | // If error also return initialValue 18 | console.error(error) 19 | return initialValue 20 | } 21 | }) 22 | 23 | // Return a wrapped version of useState's setter function that persists the new value to localStorage. 24 | const setValue = useCallback>((value) => { 25 | try { 26 | // Allow value to be a function so we have same API as useState 27 | const valueToStore = 28 | value instanceof Function ? value(storedValue) : value 29 | // Save state 30 | setStoredValue(valueToStore) 31 | // Save to local storage 32 | window.localStorage.setItem(key, JSON.stringify(valueToStore)) 33 | } catch (error) { 34 | // A more advanced implementation would handle the error case 35 | console.error(error) 36 | } 37 | }, [key, storedValue]) 38 | 39 | return [storedValue, setValue] 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/usePlayer.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useLocation, useNavigate, useParams } from 'react-router-dom' 3 | import { useRecoilValue } from 'recoil' 4 | import findPlayer from '../api/findPlayer' 5 | import routes from '../constants/routes' 6 | import activeGameState, { SelectedActiveGame } from '../state/activeGameState' 7 | import type { Player } from '../entities/player' 8 | 9 | function usePlayer(): [Player, (player: Player) => void] { 10 | const { id } = useParams() 11 | const location = useLocation() 12 | 13 | const [player, setPlayer] = useState(location.state?.player) 14 | const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame 15 | 16 | const navigate = useNavigate() 17 | 18 | useEffect(() => { 19 | (async () => { 20 | if (!player) { 21 | try { 22 | const { players } = await findPlayer(activeGame.id, id!) 23 | const player = players.find((p) => p.id === id) 24 | 25 | if (player) { 26 | setPlayer(player) 27 | } else { 28 | navigate(routes.players, { replace: true }) 29 | } 30 | } catch (err) { 31 | navigate(routes.players, { replace: true }) 32 | } 33 | } 34 | })() 35 | }, [activeGame.id, id, navigate, player]) 36 | 37 | return [player, setPlayer] 38 | } 39 | 40 | export default usePlayer 41 | -------------------------------------------------------------------------------- /src/utils/useSearch.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useDebounce } from 'use-debounce' 3 | 4 | export default function useSearch(initialSearch?: string | null) { 5 | const [search, setSearch] = useState(initialSearch ?? '') 6 | const [page, setPage] = useState(0) 7 | const [debouncedSearch] = useDebounce(search, 300) 8 | 9 | useEffect(() => { 10 | window.scrollTo(0, 0) 11 | }, [page]) 12 | 13 | useEffect(() => { 14 | setPage(0) 15 | }, [search]) 16 | 17 | return { 18 | search, 19 | setSearch, 20 | page, 21 | setPage, 22 | debouncedSearch 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/useSortedItems.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react' 2 | 3 | export default function useSortedItems(items: T[], key: keyof T, direction: 'asc' | 'desc' = 'desc'): T[] { 4 | const getComparator = useCallback(() => { 5 | const valForKey = items[0][key] as T 6 | 7 | switch (typeof valForKey) { 8 | case 'string': 9 | if (!isNaN(new Date(valForKey).getTime())) { 10 | return (a: T, b: T) => new Date(b[key] as string).getTime() - new Date(a[key] as string).getTime() 11 | } 12 | 13 | return (a: T, b: T) => (b[key] as string).localeCompare(a[key] as string) 14 | case 'number': 15 | return (a: T, b: T) => (b[key] as number) - (a[key] as number) 16 | } 17 | }, [items, key]) 18 | 19 | const sorted = useMemo(() => { 20 | if (items.length === 0) return items 21 | 22 | const sortedItems: T[] = [...items] 23 | sortedItems.sort(getComparator()) 24 | if (direction === 'asc') sortedItems.reverse() 25 | 26 | return sortedItems 27 | }, [items, getComparator, direction]) 28 | 29 | return sorted 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/useTimePeriod.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { format, sub, startOfWeek, startOfMonth, startOfYear, isToday } from 'date-fns' 3 | 4 | export type TimePeriod = '7d' | '30d' | 'w' | 'm' | 'y' 5 | 6 | export default (timePeriod: TimePeriod | null) => { 7 | const [startDate, setStartDate] = useState('') 8 | const [endDate, setEndDate] = useState('') 9 | 10 | useEffect(() => { 11 | if (timePeriod) { 12 | const isStartOfWeekToday = isToday(startOfWeek(new Date())) 13 | 14 | switch (timePeriod) { 15 | case '7d': 16 | setStartDate(format(sub(new Date(), { days: 7 }), 'yyyy-MM-dd')) 17 | break 18 | case '30d': 19 | setStartDate(format(sub(new Date(), { days: 30 }), 'yyyy-MM-dd')) 20 | break 21 | case 'w': 22 | if (isStartOfWeekToday) { 23 | setStartDate(format(sub(new Date(), { days: 7 }), 'yyyy-MM-dd')) 24 | } else { 25 | setStartDate(format(startOfWeek(new Date()), 'yyyy-MM-dd')) 26 | } 27 | break 28 | case 'm': 29 | setStartDate(format(startOfMonth(new Date()), 'yyyy-MM-dd')) 30 | break 31 | case 'y': 32 | setStartDate(format(startOfYear(new Date()), 'yyyy-MM-dd')) 33 | break 34 | } 35 | 36 | setEndDate(format(new Date(), 'yyyy-MM-dd')) 37 | } else { 38 | setStartDate('') 39 | setEndDate('') 40 | } 41 | }, [timePeriod]) 42 | 43 | return { 44 | startDate, 45 | endDate 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/validation/nullableNumber.ts: -------------------------------------------------------------------------------- 1 | export default function nullableNumber(val: unknown) { 2 | if (val === '' || val === null || typeof val === 'undefined') return null 3 | return Number(val) 4 | } 5 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | content: [ 3 | './public/**/*.html', 4 | './src/**/*.{ts,tsx}' 5 | ], 6 | theme: { 7 | extend: { 8 | minWidth: { 9 | '10': '2.5rem', 10 | '20': '5rem', 11 | '40': '10rem', 12 | '60': '15rem', 13 | '80': '20rem' 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "**/__tests__", 5 | "node_modules", 6 | "dist" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "types": ["vitest/globals", "@testing-library/jest-dom"], 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": ["src"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite' 3 | import react from '@vitejs/plugin-react-swc' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | host: '0.0.0.0', 9 | port: 8080 10 | }, 11 | test: { 12 | watch: false, 13 | globals: true, 14 | environment: 'jsdom', 15 | setupFiles: './setup-tests.js', 16 | css: false, 17 | coverage: { 18 | reporter: 'lcov', 19 | exclude: [ 20 | 'src/api', 21 | 'src/entities', 22 | 'src/constants', 23 | 'src/utils/canViewPage.ts', 24 | '**/__tests__' 25 | ] 26 | } 27 | } 28 | }) 29 | --------------------------------------------------------------------------------