diff --git a/e2e/fixtures.cy.ts b/e2e/fixtures.cy.ts
index 08561fa..fd75ea3 100644
--- a/e2e/fixtures.cy.ts
+++ b/e2e/fixtures.cy.ts
@@ -1,7 +1,9 @@
beforeEach(() => {
- cy.visit('/');
+ cy.visit('/fixtures');
cy.wait('@fixtures');
cy.get('.nav-link').contains('Fixtures').click();
+
+ cy.get('@fixtures').should('have.property', 'state', 'Complete');
});
describe('fixtures', () => {
@@ -30,8 +32,8 @@ describe('fixtures', () => {
expect(loc.pathname).to.eq('/fixtures/1049002');
});
- cy.wait(['@goals', '@goals', '@goals']).then((interceptions) => {
- const reqQuery = JSON.parse(interceptions[2].request.query.json as string);
+ cy.wait(['@goals', '@goals']).then((interceptions) => {
+ const reqQuery = JSON.parse(interceptions[1].request.query.json as string);
cy.wrap(reqQuery).its('filter').its('fixtureId').should('equal', 1049002);
});
diff --git a/e2e/goals.cy.ts b/e2e/goals.cy.ts
index acc5e76..49863b8 100644
--- a/e2e/goals.cy.ts
+++ b/e2e/goals.cy.ts
@@ -13,8 +13,6 @@ describe('Goals', () => {
expect(loc.pathname).to.eq('/goals');
});
- cy.get('@fixtures').should('have.property', 'state', 'Complete');
- cy.get('@leagues').should('have.property', 'state', 'Complete');
cy.get('@goals').should('have.property', 'state', 'Complete');
});
diff --git a/e2e/settings.cy.ts b/e2e/settings.cy.ts
index 1284422..e853761 100644
--- a/e2e/settings.cy.ts
+++ b/e2e/settings.cy.ts
@@ -1,5 +1,5 @@
beforeEach(() => {
- cy.visit('/');
+ cy.visit('/settings');
cy.get('.nav-link').contains('Settings').click();
});
diff --git a/e2e/tabs.cy.ts b/e2e/tabs.cy.ts
index b3591fb..195d8ed 100644
--- a/e2e/tabs.cy.ts
+++ b/e2e/tabs.cy.ts
@@ -3,16 +3,25 @@ describe('tabs', () => {
cy.visit('/');
cy.get('.nav-link.active').contains('Home');
+ cy.location().should((loc) => {
+ expect(loc.pathname).to.eq('/goals');
+ });
cy.get('[aria-labelledby="home-tab"]').should('be.visible');
- cy.get('[aria-labelledby="fixtures-tab"]').should('not.be.visible');
- cy.get('[aria-labelledby="settings-tab"]').should('not.be.visible');
+ cy.get('[aria-labelledby="fixtures-tab"]').should('not.exist');
+ cy.get('[aria-labelledby="settings-tab"]').should('not.exist');
cy.get('.nav-link').contains('Fixtures').click();
cy.get('.nav-link.active').contains('Fixtures');
+ cy.location().should((loc) => {
+ expect(loc.pathname).to.eq('/fixtures');
+ });
cy.get('[aria-labelledby="fixtures-tab"]').should('be.visible');
cy.get('.nav-link').contains('Settings').click();
cy.get('.nav-link.active').contains('Settings');
+ cy.location().should((loc) => {
+ expect(loc.pathname).to.eq('/settings');
+ });
cy.get('[aria-labelledby="settings-tab"]').should('be.visible');
});
});
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 9f84bf8..5270620 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1,23 +1,8 @@
+import './mocks/matchMedia.ts';
+
import {render} from '@testing-library/react';
import App from './App';
test('renders the app with no errors', () => {
- // window.matchMedia is not implemented in JSDOM so need to mock it
- // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
-
- Object.defineProperty(window, 'matchMedia', {
- writable: true,
- value: vi.fn().mockImplementation((query) => ({
- matches: false,
- media: query,
- onchange: null,
- addListener: vi.fn(), // deprecated
- removeListener: vi.fn(), // deprecated
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- });
-
render();
});
diff --git a/src/App.tsx b/src/App.tsx
index 4132959..bdcadf7 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,35 +1,58 @@
+import Header from './components/Header';
+import {ThemeProvider} from './hooks/useTheme';
import Error from './pages/Error';
import Fixture from './pages/Fixture';
+import Fixtures from './pages/Fixtures';
import Goal from './pages/Goal';
import Goals from './pages/Goals';
+import Settings from './pages/Settings';
import {createBrowserRouter, Navigate, RouterProvider} from 'react-router-dom';
const router = createBrowserRouter([
{
- path: '/',
- element: ,
- errorElement: ,
- },
- {
- path: '/goals',
- element: ,
- errorElement: ,
- },
- {
- path: '/goals/:goalId',
- element: ,
- errorElement: ,
- },
- {
- path: '/fixtures/:fixtureId',
- element: ,
- errorElement: ,
+ element: ,
+ children: [
+ {
+ path: '/',
+ element: ,
+ errorElement: ,
+ },
+ {
+ path: '/goals',
+ element: ,
+ errorElement: ,
+ },
+ {
+ path: '/fixtures',
+ element: ,
+ errorElement: ,
+ },
+ {
+ path: '/settings',
+ element: ,
+ errorElement: ,
+ },
+ {
+ path: '/goals/:goalId',
+ element: ,
+ errorElement: ,
+ },
+ {
+ path: '/fixtures/:fixtureId',
+ element: ,
+ errorElement: ,
+ },
+ ],
},
]);
function App() {
- return ;
+ return (
+
+
+
+ );
}
export default App;
diff --git a/src/components/FixtureRow.tsx b/src/components/FixtureRow.tsx
index 2db9ce2..7171a97 100644
--- a/src/components/FixtureRow.tsx
+++ b/src/components/FixtureRow.tsx
@@ -36,6 +36,8 @@ function FixtureRow({fixture}: FixtureRowProps) {
src={fixture.teams.home.logo}
alt="Home team logo"
style={{maxWidth: '20px'}}
+ width={20}
+ height={20}
>
{fixture.teams.home.name}
@@ -45,6 +47,8 @@ function FixtureRow({fixture}: FixtureRowProps) {
src={fixture.teams.away.logo}
alt="Away team logo"
style={{maxWidth: '20px'}}
+ width={20}
+ height={20}
>
{fixture.teams.away.name}
diff --git a/src/components/FixturesList.tsx b/src/components/FixturesList.tsx
index 98946ca..0349340 100644
--- a/src/components/FixturesList.tsx
+++ b/src/components/FixturesList.tsx
@@ -26,7 +26,7 @@ function FixturesList({fixtures, leagues}: FixturesListProps) {
{filteredLeagues.map((league) => (
-
+
{league.name}
<>
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 2a0a2a0..80895c6 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,26 +1,84 @@
import {useEffect, useState} from 'react';
+import {NavLink, Outlet, useMatch} from 'react-router-dom';
import logoBlack from '../assets/top90logo-black.avif';
import logoWhite from '../assets/top90logo-white.avif';
-
-interface HeaderProps {
- selectedTheme: string;
- onClick?: () => void;
-}
+import {useTheme} from '../hooks/useTheme';
const DARK = 'dark';
-function Header({selectedTheme, onClick}: HeaderProps) {
- const [logo, setLogo] = useState(logoBlack);
+function getLogo(theme: string) {
+ return theme === DARK ? logoWhite : logoBlack;
+}
+
+function Header() {
+ // Child components can attach a reset function to this state via the outlet context
+ // so that the header can reset some state that they control.
+ const [resetFn, setResetFn] = useState<() => void>();
+ const {theme} = useTheme();
+ const [logo, setLogo] = useState(getLogo(theme));
useEffect(() => {
- const logoToDisplay = selectedTheme === DARK ? logoWhite : logoBlack;
+ const logoToDisplay = getLogo(theme);
setLogo(logoToDisplay);
- }, [selectedTheme]);
+ }, [theme]);
return (
-
-
+
+
+
+
+
+
+
+ -
+ {
+ return `nav-link ${isActive ? 'active' : ''}`;
+ }}
+ id="home-tab"
+ type="button"
+ aria-controls="home"
+ aria-selected={Boolean(useMatch('/goals'))}
+ >
+ Home
+
+
+ -
+ {
+ return `nav-link ${isActive ? 'active' : ''}`;
+ }}
+ id="fixtures-tab"
+ type="button"
+ aria-controls="fixtures"
+ aria-selected={Boolean(useMatch('/fixtures'))}
+ >
+ Fixtures
+
+
+ -
+ {
+ return `nav-link ${isActive ? 'active' : ''}`;
+ }}
+ id="settings-tab"
+ type="button"
+ aria-controls="settings"
+ aria-selected={Boolean(useMatch('/settings'))}
+ >
+ Settings
+
+
+
+
+
+
+
+
);
}
diff --git a/src/components/ThemeSelect.tsx b/src/components/ThemeSelect.tsx
index 1196d2a..08f4e53 100644
--- a/src/components/ThemeSelect.tsx
+++ b/src/components/ThemeSelect.tsx
@@ -1,13 +1,11 @@
-import {getPreferredTheme} from '../lib/utils';
import Select from './Select';
interface ThemeSelectProps {
+ theme: Theme;
onChange: (value: string) => void;
}
-function ThemeSelect({onChange}: ThemeSelectProps) {
- const preferredTheme = getPreferredTheme();
-
+function ThemeSelect({theme, onChange}: ThemeSelectProps) {
return (
diff --git a/src/hooks/useTheme.tsx b/src/hooks/useTheme.tsx
new file mode 100644
index 0000000..2bce35c
--- /dev/null
+++ b/src/hooks/useTheme.tsx
@@ -0,0 +1,30 @@
+import {createContext, useContext, useState} from 'react';
+import {getPreferredTheme, setDocumentTheme} from '../lib/utils';
+
+interface ThemeContext {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+}
+
+const currentTheme = getPreferredTheme();
+
+const ThemeContext = createContext
({theme: currentTheme, setTheme: () => null});
+
+export const ThemeProvider = ({children}: {children: React.ReactNode}) => {
+ const [theme, setTheme] = useState(currentTheme);
+
+ function setGlobalTheme(theme: Theme) {
+ setTheme(theme);
+ setDocumentTheme(theme);
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useTheme = () => {
+ return useContext(ThemeContext);
+};
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 100b317..46ba95e 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,13 +1,14 @@
-export function getPreferredTheme() {
+export function getPreferredTheme(): Theme {
let storedTheme = localStorage.getItem('top90-theme');
if (storedTheme) {
- return storedTheme;
+ return storedTheme as Theme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
-export function setTheme(theme: string) {
+export function setDocumentTheme(theme: Theme) {
+ localStorage.setItem('top90-theme', theme);
document.documentElement.setAttribute('data-bs-theme', theme);
}
diff --git a/src/mocks/matchMedia.ts b/src/mocks/matchMedia.ts
new file mode 100644
index 0000000..49086ab
--- /dev/null
+++ b/src/mocks/matchMedia.ts
@@ -0,0 +1,16 @@
+// window.matchMedia is not implemented in JSDOM so need to mock it
+// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
+
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
diff --git a/src/pages/Fixture.tsx b/src/pages/Fixture.tsx
index 2dfe6c8..b24e2cd 100644
--- a/src/pages/Fixture.tsx
+++ b/src/pages/Fixture.tsx
@@ -1,23 +1,16 @@
import {useEffect, useState} from 'react';
-import {useNavigate, useParams} from 'react-router-dom';
+import {NavLink, useParams} from 'react-router-dom';
import FixtureRow from '../components/FixtureRow';
-import Header from '../components/Header';
import Video from '../components/Video';
import {getFixture, GetFixtureResponse} from '../lib/api/fixtures';
import {getGoals, GetGoalsResponse} from '../lib/api/goals';
-import {getPreferredTheme} from '../lib/utils';
function Fixture() {
const {fixtureId} = useParams();
- const navigate = useNavigate();
const [getFixtureResponse, setGetFixtureResponse] = useState();
const [getGoalsResponse, setGetGoalsResponse] = useState();
- function navigateHome() {
- navigate('/');
- }
-
useEffect(() => {
if (fixtureId) {
getFixture(fixtureId).then((data) => {
@@ -34,35 +27,29 @@ function Fixture() {
}
return (
-
-
-
-
-
-
-
-
-
-
- {getGoalsResponse?.goals?.map((goal) => (
-
-
-
- ))}
+ <>
+
+
+
+
- {(!getGoalsResponse?.goals || getGoalsResponse.goals.length === 0) && (
-
No goals found for this fixture.
- )}
+ {getGoalsResponse?.goals?.map((goal) => (
+
+
-
+ ))}
-
-
-
+ {(!getGoalsResponse?.goals || getGoalsResponse.goals.length === 0) && (
+
No goals found for this fixture.
+ )}
+
+
+
+
+ Back to homepage
+
-
+ >
);
}
diff --git a/src/pages/Fixtures.tsx b/src/pages/Fixtures.tsx
new file mode 100644
index 0000000..af07e2a
--- /dev/null
+++ b/src/pages/Fixtures.tsx
@@ -0,0 +1,31 @@
+import {useEffect, useState} from 'react';
+import FixturesList from '../components/FixturesList';
+import {getFixtures, GetFixturesResponse} from '../lib/api/fixtures';
+import {getLeagues, GetLeaguesResponse} from '../lib/api/leagues';
+
+function Fixtures() {
+ const [getFixturesResponse, setGetFixturesResponse] = useState
();
+ const [getLeaguesResponse, setGetLeaguesResponse] = useState();
+
+ useEffect(() => {
+ getFixtures({todayOnly: true}).then((data) => {
+ setGetFixturesResponse(data);
+ });
+ getLeagues().then((data) => {
+ setGetLeaguesResponse(data);
+ });
+ }, []);
+
+ return (
+
+ );
+}
+
+export default Fixtures;
diff --git a/src/pages/Goal.tsx b/src/pages/Goal.tsx
index 85a3bc3..06fc2e2 100644
--- a/src/pages/Goal.tsx
+++ b/src/pages/Goal.tsx
@@ -4,8 +4,6 @@ import {getGoal, GetGoalResponse} from '../lib/api/goals';
import {useEffect, useState} from 'react';
import {useNavigate, useParams} from 'react-router-dom';
-import Header from '../components/Header';
-import {getPreferredTheme} from '../lib/utils';
function Goal() {
const {goalId} = useParams();
@@ -29,7 +27,6 @@ function Goal() {
-
{getGoalResponse?.goal && }
diff --git a/src/pages/Goals.tsx b/src/pages/Goals.tsx
index 3090f4d..1f02678 100644
--- a/src/pages/Goals.tsx
+++ b/src/pages/Goals.tsx
@@ -1,19 +1,14 @@
import 'bootstrap/js/dist/tab';
import {useEffect, useState} from 'react';
import ReactPaginate from 'react-paginate';
-import FixturesList from '../components/FixturesList';
-import Header from '../components/Header';
+import {useOutletContext} from 'react-router-dom';
import Input from '../components/Input';
import Select from '../components/Select';
-import ThemeSelect from '../components/ThemeSelect';
import Video from '../components/Video';
import {Pagination} from '../lib/api/core';
-import {getFixtures, GetFixturesResponse} from '../lib/api/fixtures';
import {getGoals, GetGoalsFilter, GetGoalsResponse} from '../lib/api/goals';
-import {getLeagues, GetLeaguesResponse} from '../lib/api/leagues';
import {Player, searchPlayers, SearchPlayersResponse} from '../lib/api/players';
import {getTeams, GetTeamsResponse} from '../lib/api/teams';
-import {getPreferredTheme, setTheme} from '../lib/utils';
const defaultPagination: Pagination = {skip: 0, limit: 5};
@@ -24,24 +19,29 @@ function Goals() {
const [selectedTeamId, setSelectedTeamId] = useState
();
const [selectedPlayer, setSelectedPlayer] = useState();
const [searchInput, setSearchInput] = useState('');
- const [selectedTheme, setSelectedTheme] = useState(getPreferredTheme());
const [getGoalsResponse, setGetGoalsResponse] = useState();
const [getTeamsResponse, setGetTeamsResponse] = useState();
- const [getFixturesResponse, setGetFixturesResponse] = useState();
- const [getLeaguesResponse, setGetLeaguesResponse] = useState();
- const [searchPlayesResponse, setSearchPlayersResponse] = useState();
+ const [searchPlayersResponse, setSearchPlayersResponse] = useState();
+ const [_, setResetFn] = useOutletContext<[Function, React.Dispatch]>();
const pageCount = Math.ceil(
(getGoalsResponse ? getGoalsResponse.total : 0) / (pagination.limit || defaultPagination.limit)
);
+ function reset() {
+ setGetGoalsResponse(undefined);
+ getGoals().then((data) => setGetGoalsResponse(data));
+
+ setSelectedLeagueId(undefined);
+ setSelectedTeamId(undefined);
+ setSelectedPlayer(undefined);
+ setSearchInput('');
+ setCurrentPage(0);
+ setPagination(defaultPagination);
+ }
+
useEffect(() => {
- getFixtures({todayOnly: true}).then((data) => {
- setGetFixturesResponse(data);
- });
- getLeagues().then((data) => {
- setGetLeaguesResponse(data);
- });
+ setResetFn(() => () => reset());
getGoals().then((data) => {
setGetGoalsResponse(data);
});
@@ -138,203 +138,108 @@ function Goals() {
});
}
- function reset() {
- setGetGoalsResponse(undefined);
- getGoals().then((data) => setGetGoalsResponse(data));
-
- setSelectedLeagueId(undefined);
- setSelectedTeamId(undefined);
- setSelectedPlayer(undefined);
- setSearchInput('');
- setCurrentPage(0);
- setPagination(defaultPagination);
- }
-
- function changeTheme(value: string) {
- const selectedTheme = value;
- localStorage.setItem('top90-theme', selectedTheme);
- setTheme(selectedTheme);
- setSelectedTheme(selectedTheme);
- }
-
return (
-
-
-
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
- {getGoalsResponse?.goals?.map((goal) => (
-
-
-
- ))}
+
+
+
-
-
+
+
-
-
-
-
+ {getGoalsResponse?.goals?.map((goal) => (
+
+
+ ))}
+
+
+
+
+
+ {getGoalsResponse?.goals && getGoalsResponse?.goals.length > 0 && (
+
+
+
+ )}
);
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
new file mode 100644
index 0000000..fdd9446
--- /dev/null
+++ b/src/pages/Settings.tsx
@@ -0,0 +1,16 @@
+import ThemeSelect from '../components/ThemeSelect';
+import {useTheme} from '../hooks/useTheme';
+
+function Settings() {
+ const {theme, setTheme} = useTheme();
+
+ return (
+
+
+ setTheme(value as Theme)}>
+
+
+ );
+}
+
+export default Settings;
diff --git a/src/types.d.ts b/src/types.d.ts
index e2937d4..a022b26 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -1 +1,3 @@
declare module '*.png';
+
+type Theme = 'dark' | 'light';