diff --git a/.prettierrc b/.prettierrc index dfc22c0..4c3086a 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,7 @@ { - "semi": false, - "singleQuote": true, - "trailingComma": "all", - "printWidth": 100, - "tabWidth": 4 -} \ No newline at end of file + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 4 +} diff --git a/deploy/project2-backend.service b/deploy/project2-backend.service index 4eecd2a..beecaa9 100644 --- a/deploy/project2-backend.service +++ b/deploy/project2-backend.service @@ -7,7 +7,7 @@ StartLimitIntervalSec=0 [Service] Type=simple WorkingDirectory=%h/T10-project-2 -ExecStart=/bin/bash -c 'source "$HOME/.nvm/nvm.sh" && exec node src/backend/server.ts' +ExecStart=/bin/bash -c 'source "$HOME/.nvm/nvm.sh" && exec node src/backend/Server.ts' Restart=always RestartSec=5 diff --git a/eslint.config.js b/eslint.config.js index eb90efb..e6c3a9a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,18 +7,18 @@ import { defineConfig, globalIgnores } from 'eslint/config' import prettier from 'eslint-config-prettier' export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - prettier, - ], - languageOptions: { - globals: globals.browser, + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + prettier, + ], + languageOptions: { + globals: globals.browser, + }, }, - }, ]) diff --git a/index.html b/index.html index 742f3ee..6c5389d 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,13 @@ - - - - - t10-project-2 - - -
- - + + + + + t10-project-2 + + +
+ + diff --git a/package.json b/package.json index 8481ee4..4527c35 100644 --- a/package.json +++ b/package.json @@ -1,56 +1,56 @@ { - "name": "t10-project-2", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview", - "format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx,css,md}\"", - "test": "vitest", - "test:ui": "vitest --ui", - "test:coverage": "vitest run --coverage", - "server": "node src/backend/server.ts" - }, - "dependencies": { - "@apollo/server": "^5.5.1", - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.1", - "@material/card": "^14.0.0", - "@mui/material": "^9.4.0", - "dotenv": "^18.0.7", - "graphql": "^16.14.2", - "lucide-react": "^1.48.0", - "pg": "^8.23.1", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "react-router-dom": "^7.18.4" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@testing-library/jest-dom": "^7.0.1", - "@testing-library/react": "^16.3.3", - "@testing-library/user-event": "^14.6.7", - "@types/node": "^24.13.3", - "@types/pg": "^8.23.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.7", - "@vitejs/plugin-react": "^6.1.1", - "@vitest/coverage-v8": "^5.0.2", - "@vitest/ui": "^5.0.3", - "eslint": "^10.10.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.6", - "globals": "^17.12.0", - "jsdom": "^29.1.1", - "prettier": "^3.9.8", - "typescript": "~6.0.2", - "typescript-eslint": "^8.69.0", - "vite": "^8.3.0", - "vitest": "^5.0.2" - } + "name": "t10-project-2", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css,md}\"", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", + "server": "node src/backend/Server.ts" + }, + "dependencies": { + "@apollo/Server": "^5.5.1", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@material/card": "^14.0.0", + "@mui/material": "^9.4.0", + "dotenv": "^18.0.7", + "graphql": "^16.14.2", + "lucide-react": "^1.48.0", + "pg": "^8.23.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^24.13.3", + "@types/pg": "^8.23.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^5.0.2", + "@vitest/ui": "^5.0.3", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.6", + "globals": "^17.12.0", + "jsdom": "^29.1.1", + "prettier": "^3.9.8", + "typescript": "~6.0.2", + "typescript-eslint": "^8.69.0", + "vite": "^8.3.0", + "vitest": "^5.0.2" + } } diff --git a/src/App.tsx b/src/App.tsx index cbcbc14..b5f9821 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,24 @@ import { HashRouter, Routes, Route } from 'react-router-dom' -import MainPage from './pages/mainpage' -import GamePage from './pages/gamepage' -import { SearchPage } from './pages/searchpage' -import SignUpPage from './pages/signup' -import SignInPage from './pages/signin' -import WishlistPage from './pages/wishlistpage' -import ProfilePage from './pages/profilepage' +import MainPage from './pages/MainPage' +import GamePage from './pages/GamePage' +import { SearchPage } from './pages/SearchPage' +import SignUpPage from './pages/SignUp' +import SignInPage from './pages/SignIn' +import WishlistPage from './pages/WishlistPage' +import ProfilePage from './pages/ProfilePage' function App() { return ( } /> - } /> + } /> } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> ) diff --git a/src/main.tsx b/src/Main.tsx similarity index 77% rename from src/main.tsx rename to src/Main.tsx index 4d0d7f8..d03c3ef 100644 --- a/src/main.tsx +++ b/src/Main.tsx @@ -1,8 +1,8 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import './css/global.css' +import './css/Global.css' import App from './App.tsx' -import { AuthProvider } from './context/authcontext.tsx' +import { AuthProvider } from './context/AuthContext.tsx' createRoot(document.getElementById('root')!).render( diff --git a/src/api/fill_db.ts b/src/api/FillDb.ts similarity index 68% rename from src/api/fill_db.ts rename to src/api/FillDb.ts index a1db2d3..381b56a 100644 --- a/src/api/fill_db.ts +++ b/src/api/FillDb.ts @@ -1,16 +1,16 @@ -import "dotenv/config" -import { pool } from "../backend/db_connection" -import { fetchGames } from "../backend/steam" +import 'dotenv/config' +import { pool } from '../backend/DbConnection' +import { FetchGames } from '../backend/Steam' const BATCH = 200 const TARGET = 2000 const START = 200 //changed based on wether it crashes or not -async function fill_db() { +async function FillDb() { try { for (let offset = START; offset < TARGET; offset += BATCH) { - const games = await fetchGames(BATCH, offset) - for(const g of games) { + const games = await FetchGames(BATCH, offset) + for (const g of games) { await pool.query( `INSERT INTO games (game_id, game_name, current_price, initial_price, discount_percent, image_url, genre, description) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) @@ -22,7 +22,16 @@ async function fill_db() { image_url = EXCLUDED.image_url, genre = EXCLUDED.genre, description = EXCLUDED.description`, - [g.appid, g.name, g.price, g.initialprice, g.discount, g.img, g.genre, g.description] + [ + g.appid, + g.name, + g.price, + g.initialprice, + g.discount, + g.img, + g.genre, + g.description, + ], ) } console.log(`Inserted/updated ${games.length} games`) @@ -35,4 +44,4 @@ async function fill_db() { } } -fill_db() \ No newline at end of file +FillDb() diff --git a/src/api/gameClient.ts b/src/api/GameClient.ts similarity index 80% rename from src/api/gameClient.ts rename to src/api/GameClient.ts index fec47f9..796c4b7 100644 --- a/src/api/gameClient.ts +++ b/src/api/GameClient.ts @@ -1,4 +1,4 @@ -import type { Game } from '../backend/steam' +import type { Game } from '../backend/Steam' interface GraphQLResponse { data?: T @@ -13,7 +13,7 @@ interface CachedGames { games: Game[] } -function isGame(value: unknown): value is Game { +function IsGame(value: unknown): value is Game { if (typeof value !== 'object' || value === null) return false const game = value as Record @@ -29,33 +29,33 @@ function isGame(value: unknown): value is Game { ) } -function isCachedGames(value: unknown): value is CachedGames { +function IsCachedGames(value: unknown): value is CachedGames { if (typeof value !== 'object' || value === null) return false const cache = value as Record return ( typeof cache.fetchedAt === 'number' && Array.isArray(cache.games) && - cache.games.every(isGame) + cache.games.every(IsGame) ) } -function gamesCacheKey(limit: number, offset: number): string { +function GamesCacheKey(limit: number, offset: number): string { return `games:v1:${limit}:${offset}` } -function gameByIdCacheKey(appid: number): string { +function GameByIdCacheKey(appid: number): string { return `game:v1:${appid}` } -function readCachedGames(key: string): Game[] | null { +function ReadCachedGames(key: string): Game[] | null { try { const rawCache = globalThis.localStorage?.getItem(key) if (!rawCache) return null const cache: unknown = JSON.parse(rawCache) - const age = Date.now() - (isCachedGames(cache) ? cache.fetchedAt : 0) - if (isCachedGames(cache) && age >= 0 && age < CACHE_TTL_MS) { + const age = Date.now() - (IsCachedGames(cache) ? cache.fetchedAt : 0) + if (IsCachedGames(cache) && age >= 0 && age < CACHE_TTL_MS) { return cache.games } @@ -67,7 +67,7 @@ function readCachedGames(key: string): Game[] | null { return null } -function writeCachedGames(key: string, games: Game[]): void { +function WriteCachedGames(key: string, games: Game[]): void { try { globalThis.localStorage?.setItem( key, @@ -78,8 +78,8 @@ function writeCachedGames(key: string, games: Game[]): void { } } -function findCachedGameById(appid: number): Game | null { - const cachedGame = readCachedGames(gameByIdCacheKey(appid))?.find( +function FindCachedGameById(appid: number): Game | null { + const cachedGame = ReadCachedGames(GameByIdCacheKey(appid))?.find( (game) => game.appid === appid, ) if (cachedGame) return cachedGame @@ -94,7 +94,7 @@ function findCachedGameById(appid: number): Game | null { ).filter((key): key is string => key !== null && key.startsWith('games:v1:')) for (const key of cacheKeys) { - const game = readCachedGames(key)?.find((candidate) => candidate.appid === appid) + const game = ReadCachedGames(key)?.find((candidate) => candidate.appid === appid) if (game) return game } } catch (error) { @@ -104,8 +104,8 @@ function findCachedGameById(appid: number): Game | null { return null } -export async function getGameById(appid: number): Promise { - const cachedGame = findCachedGameById(appid) +export async function GetGameById(appid: number): Promise { + const cachedGame = FindCachedGameById(appid) if (cachedGame) return cachedGame const response = await fetch('http://it2810-10.idi.ntnu.no:3001/', { @@ -141,13 +141,13 @@ export async function getGameById(appid: number): Promise { if (errors?.length) throw new Error(errors[0].message) if (!data) throw new Error('No data returned') - if (data.game) writeCachedGames(gameByIdCacheKey(appid), [data.game]) + if (data.game) WriteCachedGames(GameByIdCacheKey(appid), [data.game]) return data.game } -async function fetchGamesFromDB(limit: number, offset: number): Promise { - const cacheKey = gamesCacheKey(limit, offset) - const cachedGames = readCachedGames(cacheKey) +async function FetchGamesFromDB(limit: number, offset: number): Promise { + const cacheKey = GamesCacheKey(limit, offset) + const cachedGames = ReadCachedGames(cacheKey) if (cachedGames) return cachedGames const response = await fetch('http://it2810-10.idi.ntnu.no:3001/', { @@ -183,31 +183,31 @@ async function fetchGamesFromDB(limit: number, offset: number): Promise if (errors?.length) throw new Error(errors[0].message) if (!data) throw new Error('No data returned') - writeCachedGames(cacheKey, data.games) + WriteCachedGames(cacheKey, data.games) return data.games } -export function getGames(): Promise { - return fetchAllGames() +export function GetGames(): Promise { + return FetchAllGames() } -async function fetchAllGames(): Promise { +async function FetchAllGames(): Promise { const games: Game[] = [] for (let offset = 0; ; offset += GAME_PAGE_SIZE) { - const page = await fetchGamesFromDB(GAME_PAGE_SIZE, offset) + const page = await FetchGamesFromDB(GAME_PAGE_SIZE, offset) games.push(...page) if (page.length < GAME_PAGE_SIZE) return games } } -export default fetchGamesFromDB +export default FetchGamesFromDB //This is an example of how to fetch information // import { useEffect, useState } from 'react' -// import fetchGamesFromDB from '../api/gameClient' -// import type { Game } from '../backend/steam' +// import fetchGamesFromDB from '../api/GameClient' +// import type { Game } from '../backend/Steam' // // Before return: // const [games, setGames] = useState([]) diff --git a/src/api/LogicConnection.ts b/src/api/LogicConnection.ts new file mode 100644 index 0000000..c01e7bc --- /dev/null +++ b/src/api/LogicConnection.ts @@ -0,0 +1,13 @@ +const BACKEND_URL = 'http://it2810-10.idi.ntnu.no:3001/' + +export async function Gql(query: string, variables: Record): Promise { + const res = await fetch(BACKEND_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }) + if (!res.ok) throw new Error(`Request failed: ${res.status}`) + const json = await res.json() + if (json.errors) throw new Error(json.errors[0].message) + return json.data +} diff --git a/src/api/mainpageLogicFrontend.ts b/src/api/MainPageLogicFrontend.ts similarity index 72% rename from src/api/mainpageLogicFrontend.ts rename to src/api/MainPageLogicFrontend.ts index 4d7fa33..62813a6 100644 --- a/src/api/mainpageLogicFrontend.ts +++ b/src/api/MainPageLogicFrontend.ts @@ -7,47 +7,47 @@ const mainPageGenreStorageKeys: Record = { const discountedOnlyStorageKey = 'GameStart:discountedOnlySearchpage' -function reportStorageError(action: string, error: unknown) { +function ReportStorageError(action: string, error: unknown) { const message = error instanceof Error ? error.message : String(error) console.error(`MainPage: Could not ${action} in session storage: ${message}`) } -export function getSavedMainPageGenre(section: MainPageGenreSection): string { +export function GetSavedMainPageGenre(section: MainPageGenreSection): string { try { const parsed = JSON.parse(sessionStorage.getItem(mainPageGenreStorageKeys[section]) ?? '""') return typeof parsed === 'string' ? parsed : '' } catch (error: unknown) { - reportStorageError('read the saved genre', error) + ReportStorageError('read the saved genre', error) return '' } } -export function saveMainPageGenre(section: MainPageGenreSection, genre: string) { +export function SaveMainPageGenre(section: MainPageGenreSection, genre: string) { try { sessionStorage.setItem(mainPageGenreStorageKeys[section], JSON.stringify(genre)) } catch (error: unknown) { - reportStorageError('save the genre', error) + ReportStorageError('save the genre', error) } } -export function getDiscountedOnlySearchpage(): boolean { +export function GetDiscountedOnlySearchpage(): boolean { try { return JSON.parse(sessionStorage.getItem(discountedOnlyStorageKey) ?? 'false') === true } catch (error: unknown) { - reportStorageError('read the discounted-only filter', error) + ReportStorageError('read the discounted-only filter', error) return false } } -export function saveDiscountedOnlySearchpage(discountedOnly: boolean) { +export function SaveDiscountedOnlySearchpage(discountedOnly: boolean) { try { sessionStorage.setItem(discountedOnlyStorageKey, JSON.stringify(discountedOnly)) } catch (error: unknown) { - reportStorageError('save the discounted-only filter', error) + ReportStorageError('save the discounted-only filter', error) } } -export function prepareDiscountGenreSearch(genre: string) { +export function PrepareDiscountGenreSearch(genre: string) { const searchPageValues: Record = { 'GameStart:filtersSearchpage': JSON.stringify(genre ? [genre] : []), [discountedOnlyStorageKey]: JSON.stringify(true), @@ -61,6 +61,6 @@ export function prepareDiscountGenreSearch(genre: string) { sessionStorage.setItem(key, value) } } catch (error: unknown) { - reportStorageError('prepare the discounted genre search', error) + ReportStorageError('prepare the discounted genre search', error) } } diff --git a/src/api/SearchPageLogicFrontend.ts b/src/api/SearchPageLogicFrontend.ts new file mode 100644 index 0000000..fa5ec56 --- /dev/null +++ b/src/api/SearchPageLogicFrontend.ts @@ -0,0 +1,90 @@ +const backendport = 'http://it2810-10.idi.ntnu.no:3001/' + +async function Gql(query: string, variables: Record): Promise { + const res = await fetch(backendport, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }) + if (!res.ok) throw new Error(`Request failed: ${res.status}`) + const json = await res.json() + if (json.errors) throw new Error(json.errors[0].message) + return json.data +} + +export async function FetchGenres(): Promise { + const data = await Gql<{ genres: string[] }>(`query { genres }`, {}) + return data.genres +} + +export function GetSavedFiltersSearchpage(): string[] { + try { + const parsed = JSON.parse(sessionStorage.getItem('GameStart:filtersSearchpage') ?? '[]') + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +export function SaveFiltersSearchpage(filters: string[]) { + try { + sessionStorage.setItem('GameStart:filtersSearchpage', JSON.stringify(filters)) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + console.log(`Searchpage: Could not save filters to session storage: ${message}`) + } +} + +export function GetQuerySearchpage(): string { + try { + const parsed = JSON.parse(sessionStorage.getItem('GameStart:querySearchpage') ?? '""') + return typeof parsed === 'string' ? parsed : '' + } catch { + return '' + } +} + +export function SaveQuerySearchpage(query: string) { + try { + sessionStorage.setItem('GameStart:querySearchpage', JSON.stringify(query)) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + console.log(`Searchpage: Could not save search query to session storage: ${message}`) + } +} + +export function SaveSortOptionSearchpage(query: string) { + try { + sessionStorage.setItem('GameStart:sortOptionSearchpage', JSON.stringify(query)) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + console.log(`Searchpage: Could not save sorting option to session storage: ${message}`) + } +} + +export function GetSortOptionSearchpage(): string { + try { + const parsed = JSON.parse(sessionStorage.getItem('GameStart:sortOptionSearchpage') ?? '""') + return typeof parsed === 'string' ? parsed : '' + } catch { + return '' + } +} + +export function SavePageSearchpage(pageNumber: number) { + try { + sessionStorage.setItem('GameStart:pageSearchpage', JSON.stringify(pageNumber)) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + console.log(`Searchpage: Could not save page to session storage: ${message}`) + } +} + +export function GetPageSearchpage(): number { + try { + const parsed = JSON.parse(sessionStorage.getItem('GameStart:pageSearchpage') ?? '1') + return Number.isInteger(parsed) && parsed >= 1 ? parsed : 1 + } catch { + return 1 + } +} diff --git a/src/api/userlogic_frontend.ts b/src/api/UserLogicFrontend.ts similarity index 62% rename from src/api/userlogic_frontend.ts rename to src/api/UserLogicFrontend.ts index 734fcf8..8e4f999 100644 --- a/src/api/userlogic_frontend.ts +++ b/src/api/UserLogicFrontend.ts @@ -1,59 +1,61 @@ -import { gql } from "./logicconnection" +import { Gql } from './LogicConnection' export interface User { - id: number, - email: string, + id: number + email: string username: string } const USER_FIELDS = 'id email username' // Calls upon the backend function to sign in a user. -export async function signinUser(email: string, password: string): Promise { - const data = await gql<{ signin: User }>( +export async function SigninUser(email: string, password: string): Promise { + const data = await Gql<{ signin: User }>( `mutation($email: String!, $password: String!) { signin(email: $email, password: $password) { ${USER_FIELDS} } }`, - { email, password } + { email, password }, ) return data.signin } // Calls upon the backend function to sign up a user. -export async function signupUser(username: string, email: string, password: string): Promise { - const data = await gql<{ signup: User }>( +export async function SignupUser(username: string, email: string, password: string): Promise { + const data = await Gql<{ signup: User }>( `mutation($username: String!, $email: String!, $password: String!) { signup(username: $username, email: $email password: $password) { ${USER_FIELDS} } }`, - { username, email, password } + { username, email, password }, ) return data.signup } //If the user wants to be remembered, their user will be saved to the localstorage. //If the user does not want to be remembered, they are saved to sessionstorage. -export function saveUser(user: User, remember = true) { +export function SaveUser(user: User, remember = true) { try { - clearUser() + ClearUser() const storage = remember ? localStorage : sessionStorage storage.setItem('GameStart:user', JSON.stringify(user)) - } catch (error: any) { - console.log(`Could not save user information to storage: ${error.message}`) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + console.log(`Could not save user information to storage: ${message}`) } } // Returns the saved user or null if no user is found. -export function getSavedUser(): User | null { +export function GetSavedUser(): User | null { try { - const raw = localStorage.getItem('GameStart:user') ?? sessionStorage.getItem('GameStart:user') + const raw = + localStorage.getItem('GameStart:user') ?? sessionStorage.getItem('GameStart:user') return raw ? (JSON.parse(raw) as User) : null - } catch (error) { + } catch { return null } } //Clears the current user -export function clearUser() { +export function ClearUser() { localStorage.removeItem('GameStart:user') sessionStorage.removeItem('GameStart:user') -} \ No newline at end of file +} diff --git a/src/api/WishlistLogicFrontend.ts b/src/api/WishlistLogicFrontend.ts new file mode 100644 index 0000000..46b70d7 --- /dev/null +++ b/src/api/WishlistLogicFrontend.ts @@ -0,0 +1,73 @@ +import { type Game } from '../backend/Steam' + +const backendport = 'http://it2810-10.idi.ntnu.no:3001/' + +async function Gql(query: string, variables: Record): Promise { + const res = await fetch(backendport, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }) + if (!res.ok) throw new Error(`Request failed: ${res.status}`) + const json = await res.json() + if (json.errors) throw new Error(json.errors[0].message) + return json.data +} + +// Frontend function for calling backend to fetch wishlisted games of a user +export async function FetchWishlistFrontend(userId: number | null): Promise { + const data = await Gql<{ wishlist: Game[] }>( + `query($userId: Int!) { + wishlist(userId: $userId) { + appid name price initialprice discount img genre description + } + }`, + { userId }, + ) + return data.wishlist +} + +// Frontend function for calling backend to add wishlisted game to a user +export async function AddToWishlistFrontend(userId: number, gameId: number): Promise { + await Gql( + `mutation($userId: Int!, $gameId: Int!) { addToWishlist(userId: $userId, gameId: $gameId) }`, + { userId, gameId }, + ) +} + +// Frontend function for calling backend to remove wishlisted game from a user +export async function RemoveFromWishlistFrontend(userId: number, gameId: number): Promise { + await Gql( + `mutation($userId: Int!, $gameId: Int!) { removeFromWishlist(userId: $userId, gameId: $gameId) }`, + { userId, gameId }, + ) +} + +// Frontend function for toggeling a game to and from the wishlist +export function ToggleGameWishlist( + userId: number, + gameId: number, + isWishlisted: boolean, +): Promise { + return isWishlisted + ? RemoveFromWishlistFrontend(userId, gameId) + : AddToWishlistFrontend(userId, gameId) +} + +export function SaveSortOptionWishlist(query: string) { + try { + sessionStorage.setItem('GameStart:sortOptionWishlist', JSON.stringify(query)) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + console.log(`Searchpage: Could not save sorting option to session storage: ${message}`) + } +} + +export function GetSortOptionWishlist(): string { + try { + const parsed = JSON.parse(sessionStorage.getItem('GameStart:sortOptionWishlist') ?? '""') + return typeof parsed === 'string' ? parsed : '' + } catch { + return 'discount-desc' + } +} diff --git a/src/api/games.ts b/src/api/games.ts deleted file mode 100644 index 0f87474..0000000 --- a/src/api/games.ts +++ /dev/null @@ -1,18 +0,0 @@ -import barotrauma from '../assets/barotrauma.png' -import bigWalk from '../assets/bigwalk.png' -import silksong from '../assets/silksong.png' -import slayTheSpire from '../assets/slaythespire.png' -import mockGames from './mockdata.json' -import type { GameData } from '../types/game' - -const imagesById: Record = { - 1: bigWalk, - 2: barotrauma, - 3: silksong, - 4: slayTheSpire, -} - -export const games: GameData[] = mockGames.map((game) => ({ - ...game, - image: imagesById[game.id], -})) diff --git a/src/api/logicconnection.ts b/src/api/logicconnection.ts deleted file mode 100644 index 154894a..0000000 --- a/src/api/logicconnection.ts +++ /dev/null @@ -1,14 +0,0 @@ - -const BACKEND_URL = 'http://it2810-10.idi.ntnu.no:3001/' - -export async function gql(query: string, variables: Record): Promise { - const res = await fetch(BACKEND_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query, variables }), - }); - if (!res.ok) throw new Error(`Request failed: ${res.status}`); - const json = await res.json(); - if (json.errors) throw new Error(json.errors[0].message); - return json.data; -} \ No newline at end of file diff --git a/src/api/mockdata.json b/src/api/mockdata.json deleted file mode 100644 index 641d7c9..0000000 --- a/src/api/mockdata.json +++ /dev/null @@ -1,61 +0,0 @@ -[ - { - "id": 1, - "name": "Big Walk", - "price": 19.90, - "genre": [ - "Co-op", - "Exploration", - "Adventure" - ], - "developer": "House House", - "description": "Big Walk is a cooperative multiplayer adventure about teamwork and talking. Set out with your friends through a wide-open world full of challenges, puzzles, and discoveries. You’ll need to work together to find your way around, stay in contact using an assortment of tools and toys, and figure out new ways to communicate when you suddenly find yourself speechless.Adventuring aside, there’s plenty of time to just hang out. Sit and watch the sunset, or steal your friend’s binoculars and kick them into the ocean. Spending time with friends is the best part of a big walk.", - "image": "../assets/bigwalk.png", - "salePercent": 10 - }, - { - "id": 2, - "name": "Barotrauma", - "price": 39.20, - "genre": [ - "Co-op", - "Multiplayer", - "Action", - "Simulation", - "Strategy" - ], - "developer": "FakeFish, Undertow Games", - "description": "Barotrauma is a 2D co-op survival horror submarine simulator, inspired by games like FTL: Faster Than Light, Rimworld, Dwarf Fortress and Space Station 13. It’s a Sci-Fi game that combines ragdoll physics and alien sea monsters with teamwork and existential fear.", - "image": "../assets/barotrauma.png", - "salePercent": 5 - }, - { - "id": 3, - "name": "Hollow Knight: Silksong", - "price": 22.75, - "genre": [ - "Indie", - "Action", - "Adventure" - ], - "developer": "Team Cherry", - "description": "As the lethal hunter Hornet, adventure through a kingdom ruled by silk and song! Captured and taken to this unfamiliar world, prepare to battle mighty foes and solve ancient mysteries as you ascend on a deadly pilgrimage to the kingdom’s peak. Hollow Knight: Silksong is the epic sequel to Hollow Knight, the award winning action-adventure. Journey to all-new lands, discover new powers, battle vast hordes of bugs and beasts and uncover secrets tied to your nature and your past.", - "image": "../assets/silksong.png", - "salePercent": 0 - }, - { - "id": 4, - "name": "Slay the Spire", - "price": 24.31, - "genre": [ - "Card Game", - "Indie", - "Strategy", - "Rougelike" - ], - "developer": "Mega Crit", - "description": "“The devs have taken the best of the deckbuilding and roguelike genres to craft an immensely deep, frustrating, and gratifying experience. Slay the Spire is a perfect example of “easy to learn, hard to master”.” 10/10 – Goomba Stomp", - "image": "../assets/slaythespire.png", - "salePercent": 0 - } -] \ No newline at end of file diff --git a/src/api/searchpageLogicFrontend.ts b/src/api/searchpageLogicFrontend.ts deleted file mode 100644 index 4cd0c51..0000000 --- a/src/api/searchpageLogicFrontend.ts +++ /dev/null @@ -1,87 +0,0 @@ -const backendport = 'http://it2810-10.idi.ntnu.no:3001/' - -async function gql(query: string, variables: Record): Promise { - const res = await fetch(backendport, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query, variables }), - }); - if (!res.ok) throw new Error(`Request failed: ${res.status}`); - const json = await res.json(); - if (json.errors) throw new Error(json.errors[0].message); - return json.data; -} - -export async function fetchGenres(): Promise { - const data = await gql<{ genres: string[] }>(`query { genres }`, {}); - return data.genres; -} - -export function getSavedFiltersSearchpage(): string[] { - try { - const parsed = JSON.parse(sessionStorage.getItem("GameStart:filtersSearchpage") ?? "[]"); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -export function saveFiltersSearchpage(filters: string[]) { - try { - sessionStorage.setItem("GameStart:filtersSearchpage", JSON.stringify(filters)); - } catch (error: any) { - console.log(`Searchpage: Could not save filters to session storage: ${error.message}`) - } -} - - -export function getQuerySearchpage(): string { - try { - const parsed = JSON.parse(sessionStorage.getItem("GameStart:querySearchpage") ?? '""'); - return typeof parsed === "string" ? parsed : ""; - } catch { - return ""; - } -} - -export function saveQuerySearchpage(query: string) { - try { - sessionStorage.setItem("GameStart:querySearchpage", JSON.stringify(query)); - } catch (error: any) { - console.log(`Searchpage: Could not save search query to session storage: ${error.message}`) - } -} - -export function saveSortOptionSearchpage(query: string) { - try { - sessionStorage.setItem("GameStart:sortOptionSearchpage", JSON.stringify(query)); - } catch (error: any) { - console.log(`Searchpage: Could not save sorting option to session storage: ${error.message}`) - } -} - -export function getSortOptionSearchpage(): string { - try { - const parsed = JSON.parse(sessionStorage.getItem("GameStart:sortOptionSearchpage") ?? '""'); - return typeof parsed === "string" ? parsed : ""; - } catch { - return ""; - } -} - -export function savePageSearchpage(pageNumber: number) { - try { - sessionStorage.setItem("GameStart:pageSearchpage", JSON.stringify(pageNumber)); - } catch (error: any) { - console.log(`Searchpage: Could not save page to session storage: ${error.message}`) - } -} - -export function getPageSearchpage(): number { - try { - const parsed = JSON.parse(sessionStorage.getItem("GameStart:pageSearchpage") ?? "1"); - return Number.isInteger(parsed) && parsed >= 1 ? parsed : 1; - } catch { - return 1; - } -} \ No newline at end of file diff --git a/src/api/wishlistLogicFrontend.ts b/src/api/wishlistLogicFrontend.ts deleted file mode 100644 index 6d39080..0000000 --- a/src/api/wishlistLogicFrontend.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { type Game } from '../backend/steam' - -const backendport = 'http://it2810-10.idi.ntnu.no:3001/' - -async function gql(query: string, variables: Record): Promise { - const res = await fetch(backendport, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query, variables }), - }); - if (!res.ok) throw new Error(`Request failed: ${res.status}`); - const json = await res.json(); - if (json.errors) throw new Error(json.errors[0].message); - return json.data; -} - -// Frontend function for calling backend to fetch wishlisted games of a user -export async function fetchWishlistFrontend(userId: number | null): Promise { - const data = await gql<{ wishlist: Game[] }>( - `query($userId: Int!) { - wishlist(userId: $userId) { - appid name price initialprice discount img genre description - } - }`, - { userId } - ); - return data.wishlist; -} - -// Frontend function for calling backend to add wishlisted game to a user -export async function addToWishlistFrontend(userId: number, gameId: number): Promise { - await gql( - `mutation($userId: Int!, $gameId: Int!) { addToWishlist(userId: $userId, gameId: $gameId) }`, - { userId, gameId } - ); -} - -// Frontend function for calling backend to remove wishlisted game from a user -export async function removeFromWishlistFrontend(userId: number, gameId: number): Promise { - await gql( - `mutation($userId: Int!, $gameId: Int!) { removeFromWishlist(userId: $userId, gameId: $gameId) }`, - { userId, gameId } - ); -} - -// Frontend function for toggeling a game to and from the wishlist -export function toggleGameWishlist( - userId: number, - gameId: number, - isWishlisted: boolean - ): Promise { - return isWishlisted - ? removeFromWishlistFrontend(userId, gameId) - : addToWishlistFrontend(userId, gameId) - } - -export function saveSortOptionWishlist(query: string) { - try { - sessionStorage.setItem("GameStart:sortOptionWishlist", JSON.stringify(query)); - } catch (error: any) { - console.log(`Searchpage: Could not save sorting option to session storage: ${error.message}`) - } -} - -export function getSortOptionWishlist(): string { - try { - const parsed = JSON.parse(sessionStorage.getItem("GameStart:sortOptionWishlist") ?? '""'); - return typeof parsed === "string" ? parsed : ""; - } catch { - return "discount-desc"; - } -} \ No newline at end of file diff --git a/src/assets/logo_gamestart_transparent.png b/src/assets/LogoGameStartTransparent.png similarity index 100% rename from src/assets/logo_gamestart_transparent.png rename to src/assets/LogoGameStartTransparent.png diff --git a/src/assets/barotrauma.png b/src/assets/barotrauma.png deleted file mode 100644 index a483105..0000000 Binary files a/src/assets/barotrauma.png and /dev/null differ diff --git a/src/assets/bigwalk.png b/src/assets/bigwalk.png deleted file mode 100644 index 785d43f..0000000 Binary files a/src/assets/bigwalk.png and /dev/null differ diff --git a/src/assets/silksong.png b/src/assets/silksong.png deleted file mode 100644 index d901d9c..0000000 Binary files a/src/assets/silksong.png and /dev/null differ diff --git a/src/assets/slaythespire.png b/src/assets/slaythespire.png deleted file mode 100644 index 1d8e076..0000000 Binary files a/src/assets/slaythespire.png and /dev/null differ diff --git a/src/backend/DbConnection.ts b/src/backend/DbConnection.ts new file mode 100644 index 0000000..8124db6 --- /dev/null +++ b/src/backend/DbConnection.ts @@ -0,0 +1,10 @@ +import 'dotenv/config' +import { Pool } from 'pg' + +export const pool = new Pool({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT), + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, +}) diff --git a/src/backend/genres.ts b/src/backend/Genres.ts similarity index 65% rename from src/backend/genres.ts rename to src/backend/Genres.ts index e1b1ab3..cdf352e 100644 --- a/src/backend/genres.ts +++ b/src/backend/Genres.ts @@ -1,11 +1,11 @@ -import { pool } from './db_connection.ts' +import { pool } from './DbConnection.ts' -export async function fetchGenres(): Promise { +export async function FetchGenres(): Promise { const { rows } = await pool.query( `SELECT DISTINCT TRIM(g) AS genre FROM games, unnest(string_to_array(genre, ',')) AS g WHERE genre IS NOT NULL AND TRIM(g) <> '' - ORDER BY genre` + ORDER BY genre`, ) return rows.map((r) => r.genre) -} \ No newline at end of file +} diff --git a/src/backend/server.ts b/src/backend/Server.ts similarity index 78% rename from src/backend/server.ts rename to src/backend/Server.ts index 2ecb143..d98270e 100644 --- a/src/backend/server.ts +++ b/src/backend/Server.ts @@ -1,10 +1,10 @@ -import { ApolloServer } from '@apollo/server' +import { ApolloServer } from '@apollo/Server' import { startStandaloneServer } from '@apollo/server/standalone' -import { type Game } from './steam.ts' -import { pool } from './db_connection.ts' -import { fetchGenres } from './genres.ts' -import { signin, signup } from './userlogic_backend.ts' -import { addToWishlist, removeFromWishlist } from './wishlistLogic.ts' +import { type Game } from './Steam.ts' +import { pool } from './DbConnection.ts' +import { FetchGenres } from './Genres.ts' +import { Signin, Signup } from './UserLogicBackend.ts' +import { AddToWishlist, RemoveFromWishlist } from './WishlistLogic.ts' // Documentation on apollo: https://www.apollographql.com/docs/deploy-preview/5dc6222f824f9e089e2a5735/apollo-server/api/apollo-server @@ -78,7 +78,7 @@ const resolvers = { JOIN games g ON g.game_id = w.game_id WHERE w.user_id = $1 ORDER BY g.game_name`, - [userId] + [userId], ) return rows }, @@ -98,21 +98,29 @@ const resolvers = { ) return rows[0] ?? null }, - genres: () => fetchGenres(), + genres: () => FetchGenres(), }, Mutation: { - addToWishlist: async (_: unknown, { userId, gameId }: { userId: number; gameId: number }) => { - await addToWishlist(userId, gameId) + addToWishlist: async ( + _: unknown, + { userId, gameId }: { userId: number; gameId: number }, + ) => { + await AddToWishlist(userId, gameId) return true }, - removeFromWishlist: async (_: unknown, { userId, gameId }: { userId: number; gameId: number }) => { - await removeFromWishlist(userId, gameId) + removeFromWishlist: async ( + _: unknown, + { userId, gameId }: { userId: number; gameId: number }, + ) => { + await RemoveFromWishlist(userId, gameId) return true }, - signup: (_parent: unknown, { username, email, password }: { username: string; email:string; password: string }) => - signup(username, email, password), + signup: ( + _parent: unknown, + { username, email, password }: { username: string; email: string; password: string }, + ) => Signup(username, email, password), signin: (_p: unknown, { email, password }: { email: string; password: string }) => - signin(email, password), + Signin(email, password), }, } diff --git a/src/backend/steam.ts b/src/backend/Steam.ts similarity index 80% rename from src/backend/steam.ts rename to src/backend/Steam.ts index 67acfff..bd3cc3e 100644 --- a/src/backend/steam.ts +++ b/src/backend/Steam.ts @@ -37,13 +37,13 @@ const PAGE_SIZE = 1000 const pageCache = new Map() //Because the bulk fetch happens page wise, we have to know which page to continue fetching from. This function will return the correct page. -async function fetchPage(page: number): Promise { +async function FetchPage(page: number): Promise { const cached_page = pageCache.get(page) - if(cached_page) return cached_page + if (cached_page) return cached_page const response = await fetch(`https://steamspy.com/api.php?request=all&page=${page}`) - if(!response.ok) { + if (!response.ok) { throw new Error(`SteamSpy request failed: ${response.status}`) } @@ -53,26 +53,24 @@ async function fetchPage(page: number): Promise { } // Run this to start the Api fetch. Will run all the other functions. -export async function fetchGames(limit: number, offset = 0, delayMs = 300): Promise { - const bulkGames = await fetchBulkGames(limit, offset) - return fillAll(bulkGames, delayMs) +export async function FetchGames(limit: number, offset = 0, delayMs = 300): Promise { + const bulkGames = await FetchBulkGames(limit, offset) + return FillAll(bulkGames, delayMs) } //This contains all the api calls we can do: https://www.steamspy.com/api.php // This fetches "limit" amount of games as a bulk from point "offset" and removes all apps that are hidden (app id = 99999) -export async function fetchBulkGames(limit: number, offset: number): Promise { +export async function FetchBulkGames(limit: number, offset: number): Promise { const firstPage = Math.floor(offset / PAGE_SIZE) const lastPage = Math.floor((offset + limit - 1) / PAGE_SIZE) const all = [] - for(let page = firstPage; page <= lastPage; page++) { - all.push(...Object.values(await fetchPage(page))) + for (let page = firstPage; page <= lastPage; page++) { + all.push(...Object.values(await FetchPage(page))) } const start = offset - firstPage * PAGE_SIZE - const games = all - .slice(start, start + limit) - .filter((g) => g.appid !== HIDDEN_APP_ID) + const games = all.slice(start, start + limit).filter((g) => g.appid !== HIDDEN_APP_ID) return games.map((g) => ({ appid: g.appid, @@ -88,10 +86,10 @@ export async function fetchBulkGames(limit: number, offset: number): Promise { +export async function FillAll(games: Game[], delayMs = 300): Promise { const filled_games: Game[] = [] for (const game of games) { - filled_games.push(await fillGame(game)) + filled_games.push(await FillGame(game)) if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)) } @@ -100,7 +98,7 @@ export async function fillAll(games: Game[], delayMs = 300): Promise { } // The "enrichment chamber" -export async function fillGame(game: Game): Promise { +export async function FillGame(game: Game): Promise { const response = await fetch( `https://store.steampowered.com/api/appdetails?appids=${game.appid}`, ) @@ -123,6 +121,6 @@ export async function fillGame(game: Game): Promise { } // This is for test purposes only -export function clearPageCache() { +export function ClearPageCache() { pageCache.clear() -} \ No newline at end of file +} diff --git a/src/backend/UserLogicBackend.ts b/src/backend/UserLogicBackend.ts new file mode 100644 index 0000000..880ba41 --- /dev/null +++ b/src/backend/UserLogicBackend.ts @@ -0,0 +1,56 @@ +import { pool } from './DbConnection.ts' + +export interface User { + id: number + email: string + username: string +} + +function NormalizeEmail(email: string) { + return email.trim().toLowerCase() +} + +export async function Signup(username: string, email: string, password: string): Promise { + if (!username || !password || !email) { + throw new Error('Email, username and password are required.') + } + if (!/\S+@\S+\.\S+/.test(NormalizeEmail(email))) { + throw new Error('Please enter a valid email address.') + } + try { + const { rows } = await pool.query( + `INSERT INTO users (user_name, password_hash, email) + VALUES ($1, $2, $3) + RETURNING user_id, user_name, email`, + [username, password, NormalizeEmail(email)], + ) + return { id: rows[0].user_id, email: rows[0].email, username: rows[0].user_name } + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === '23505' + ) { + throw new Error('An account with this email already exists.', { cause: error }) + } else { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Could not register user: ${message}`, { cause: error }) + } + } +} + +export async function Signin(email: string, password: string): Promise { + const { rows } = await pool.query( + `SELECT user_id, user_name, email + FROM users + WHERE email = $1 AND password_hash = $2 + ORDER BY user_id + LIMIT 1`, + [NormalizeEmail(email), password], + ) + if (rows.length == 0) { + throw new Error('User not found. Invalid email or password.') + } + return { id: rows[0].user_id, email: rows[0].email, username: rows[0].user_name } +} diff --git a/src/backend/WishlistLogic.ts b/src/backend/WishlistLogic.ts new file mode 100644 index 0000000..ac87728 --- /dev/null +++ b/src/backend/WishlistLogic.ts @@ -0,0 +1,36 @@ +import { pool } from './DbConnection.ts' + +export async function AddToWishlist( + userId: number, + gameId: number, +): Promise<{ ok: true; gameId: number }> { + if (!userId) throw new Error('addToWishlist: userId can not be empty') + if (!gameId) throw new Error('addToWishlist: gameId can not be empty') + + try { + await pool.query( + `INSERT INTO wishlist (user_id, game_id) + VALUES ($1, $2) + ON CONFLICT (user_id, game_id) DO NOTHING`, + [userId, gameId], + ) + } catch (err: unknown) { + // 23503 = foreign key violation (user or game doesn't exist) + if (typeof err === 'object' && err !== null && 'code' in err && err.code === '23503') { + throw new Error('addToWishlist: user or game does not exist', { cause: err }) + } + throw err + } + return { ok: true, gameId } +} + +export async function RemoveFromWishlist( + userId: number, + gameId: number, +): Promise<{ ok: true; gameId: number }> { + if (!userId) throw new Error('removeFromWishlist: userId can not be empty') + if (!gameId) throw new Error('removeFromWishlist: gameId can not be empty') + + await pool.query('DELETE FROM wishlist WHERE user_id = $1 AND game_id = $2', [userId, gameId]) + return { ok: true, gameId } +} diff --git a/src/backend/db_connection.ts b/src/backend/db_connection.ts deleted file mode 100644 index 1e992d5..0000000 --- a/src/backend/db_connection.ts +++ /dev/null @@ -1,10 +0,0 @@ -import "dotenv/config"; -import { Pool } from "pg"; - -export const pool = new Pool({ - host: process.env.DB_HOST, - port: Number(process.env.DB_PORT), - database: process.env.DB_NAME, - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, -}); \ No newline at end of file diff --git a/src/backend/userlogic_backend.ts b/src/backend/userlogic_backend.ts deleted file mode 100644 index 9254cb5..0000000 --- a/src/backend/userlogic_backend.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { pool } from './db_connection.ts' - -export interface User{ - id: number, - email: string, - username: string -} - -function normalizeEmail(email: string) { - return email.trim().toLowerCase() -} - -export async function signup(username:string, email: string, password:string): Promise { - if(!username || !password || !email) { - throw new Error("Email, username and password are required.") - } - if (!/\S+@\S+\.\S+/.test(normalizeEmail(email))) { - throw new Error('Please enter a valid email address.') - } - try { - const {rows} = await pool.query( - `INSERT INTO users (user_name, password_hash, email) - VALUES ($1, $2, $3) - RETURNING user_id, user_name, email`, - [username, password, normalizeEmail(email)] - ) - return {id: rows[0].user_id, email: rows[0].email, username: rows[0].user_name} - } catch (error: any) { - if(error.code === '23505'){ - throw new Error(`An account with this email already exists.`) - } - else { - throw new Error(`Could not register user: ${error.message}`) - } - } -} - -export async function signin(email: string, password: string): Promise { - const {rows} = await pool.query( - `SELECT user_id, user_name, email - FROM users - WHERE email = $1 AND password_hash = $2 - ORDER BY user_id - LIMIT 1`, - [normalizeEmail(email), password] - ) - if(rows.length == 0) { - throw new Error("User not found. Invalid email or password.") - } - return {id: rows[0].user_id, email: rows[0].email, username: rows[0].user_name} -} \ No newline at end of file diff --git a/src/backend/wishlistLogic.ts b/src/backend/wishlistLogic.ts deleted file mode 100644 index 3542760..0000000 --- a/src/backend/wishlistLogic.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { pool } from './db_connection.ts' - -export async function addToWishlist(userId: number, gameId: number): Promise<{ ok: true; gameId: number }> { - if (!userId) throw new Error("addToWishlist: userId can not be empty") - if (!gameId) throw new Error("addToWishlist: gameId can not be empty") - - try { - await pool.query( - `INSERT INTO wishlist (user_id, game_id) - VALUES ($1, $2) - ON CONFLICT (user_id, game_id) DO NOTHING`, - [userId, gameId] - ) - } catch (err: any) { - // 23503 = foreign key violation (user or game doesn't exist) - if (err.code === '23503') throw new Error("addToWishlist: user or game does not exist") - throw err - } - return { ok: true, gameId } -} - -export async function removeFromWishlist(userId: number, gameId: number): Promise<{ ok: true; gameId: number }> { - if (!userId) throw new Error("removeFromWishlist: userId can not be empty") - if (!gameId) throw new Error("removeFromWishlist: gameId can not be empty") - - await pool.query( - 'DELETE FROM wishlist WHERE user_id = $1 AND game_id = $2', - [userId, gameId] - ) - return { ok: true, gameId } -} \ No newline at end of file diff --git a/src/components/activegamecard.tsx b/src/components/ActiveGameCard.tsx similarity index 84% rename from src/components/activegamecard.tsx rename to src/components/ActiveGameCard.tsx index ea02a6b..d04504d 100644 --- a/src/components/activegamecard.tsx +++ b/src/components/ActiveGameCard.tsx @@ -1,12 +1,13 @@ import { Box, Button, Card, CardContent, CardMedia, Chip, Divider, Typography } from '@mui/material' -import '../css/activegamecard.css' -import type { GameData } from '../types/game' -import { getSavedUser } from '../api/userlogic_frontend' +import '../css/ActiveGameCard.css' +import type { GameData } from '../types/Game' +import { GetSavedUser } from '../api/UserLogicFrontend' import { useState, useEffect } from 'react' -import { removeFromWishlistFrontend, - addToWishlistFrontend, - fetchWishlistFrontend } - from '../api/wishlistLogicFrontend' +import { + RemoveFromWishlistFrontend, + AddToWishlistFrontend, + FetchWishlistFrontend, +} from '../api/WishlistLogicFrontend' type ActiveGameCardProps = { game: GameData @@ -14,28 +15,30 @@ type ActiveGameCardProps = { onAddToWishlist?: (gameId: number) => void } -export default function ActiveGameCard({ game, onAddToWishlist, onRemoveFromWishlist }: ActiveGameCardProps) { +export default function ActiveGameCard({ + game, + onAddToWishlist, + onRemoveFromWishlist, +}: ActiveGameCardProps) { const [isWishlisted, setIsWishlisted] = useState(false) - const [isChecking, setIsChecking] = useState(true) + const [isChecking, setIsChecking] = useState(() => GetSavedUser() !== null) const [isBusy, setIsBusy] = useState(false) const [error, setError] = useState(null) const isOnSale = game.salePercent !== undefined && game.salePercent > 0 && game.salePercent <= 100 - + // Finds out if the signed in user already has this game on their wishlist useEffect(() => { let cancelled = false - const user = getSavedUser() - - if (!user) { - setIsChecking(false) - return - } - - fetchWishlistFrontend(user.id) + const user = GetSavedUser() + + if (!user) return + + FetchWishlistFrontend(user.id) .then((wishlist) => { - if (!cancelled) setIsWishlisted(wishlist.some((wishlisted) => wishlisted.appid === game.id)) + if (!cancelled) + setIsWishlisted(wishlist.some((wishlisted) => wishlisted.appid === game.id)) }) .catch(() => { if (!cancelled) setError('Could not check your wishlist') @@ -43,29 +46,29 @@ export default function ActiveGameCard({ game, onAddToWishlist, onRemoveFromWish .finally(() => { if (!cancelled) setIsChecking(false) }) - + return () => { cancelled = true } }, [game.id]) // Adds the game to the wishlist, or removes it if it is already there - async function handleToggleWishlist() { - const user = getSavedUser() + async function HandleToggleWishlist() { + const user = GetSavedUser() if (!user) { setError('You need to sign in to change your wishlist') return } - + setIsBusy(true) setError(null) try { if (isWishlisted) { - await removeFromWishlistFrontend(user.id, game.id) + await RemoveFromWishlistFrontend(user.id, game.id) setIsWishlisted(false) onRemoveFromWishlist?.(game.id) } else { - await addToWishlistFrontend(user.id, game.id) + await AddToWishlistFrontend(user.id, game.id) setIsWishlisted(true) onAddToWishlist?.(game.id) } @@ -89,7 +92,7 @@ export default function ActiveGameCard({ game, onAddToWishlist, onRemoveFromWish className="activeGameWishlistButton" variant="contained" disabled={isChecking || isBusy} - onClick={handleToggleWishlist} + onClick={HandleToggleWishlist} > {isWishlisted ? 'Remove from wishlist' : 'Add to wishlist'} @@ -161,9 +164,9 @@ export default function ActiveGameCard({ game, onAddToWishlist, onRemoveFromWish /* How to use this: /* The below may be inserted under the imports section of a page. -import ActiveGameCard from '../components/activegamecard' -import games from '../api/mockdata.json' -import bigWalk from '../assets/bigwalk.png' +import ActiveGameCard from '../components/ActiveGameCard' +import games from '../api/MockData.json' +import bigWalk from '../assets/BigWalk.png' */ /* The following may be put underneath the export function part of a page, example is written with ''' around it, the code itself is the const. diff --git a/src/components/AuthTheme.ts b/src/components/AuthTheme.ts new file mode 100644 index 0000000..886325c --- /dev/null +++ b/src/components/AuthTheme.ts @@ -0,0 +1,14 @@ +import { createTheme } from '@mui/material/styles' + +const AuthTheme = createTheme({ + palette: { + primary: { + main: '#27b722', + light: '#63ce5c', + dark: '#1b8317', + contrastText: '#000000', + }, + }, +}) + +export default AuthTheme diff --git a/src/components/footer.tsx b/src/components/Footer.tsx similarity index 73% rename from src/components/footer.tsx rename to src/components/Footer.tsx index 57bac3f..59df83e 100644 --- a/src/components/footer.tsx +++ b/src/components/Footer.tsx @@ -1,11 +1,11 @@ -import '../css/footer.css' -import gameStartLogo from '../assets/logo_gamestart_transparent.png' +import '../css/Footer.css' +import gameStartLogo from '../assets/LogoGameStartTransparent.png' import { Divider } from '@mui/material' export default function Footer() { return (
- +

GameStart© 2026 diff --git a/src/components/header.tsx b/src/components/Header.tsx similarity index 79% rename from src/components/header.tsx rename to src/components/Header.tsx index 79a43ba..6e8c422 100644 --- a/src/components/header.tsx +++ b/src/components/Header.tsx @@ -1,20 +1,20 @@ import { Link, NavLink } from 'react-router-dom' -import '../css/header.css' +import '../css/Header.css' import { House, UserRound, Search, Sparkles, LogIn, LogOut } from 'lucide-react' -import gameStartLogo from '../assets/logo_gamestart_transparent.png' +import gameStartLogo from '../assets/LogoGameStartTransparent.png' import Divider from '@mui/material/Divider' -import { useAuth } from '../context/authcontext' +import { UseAuth } from '../context/AuthContext' export default function Header() { - const { user, logout } = useAuth() + const { user, logout } = UseAuth() const navigationLinks = [ { to: '/', label: 'Home', icon: }, - { to: '/searchpage', label: 'Search', icon: }, - { to: '/wishlistpage', label: 'Wishlist', icon: }, - { to: '/profilepage', label: 'Profile', icon: }, + { to: '/SearchPage', label: 'Search', icon: }, + { to: '/WishlistPage', label: 'Wishlist', icon: }, + { to: '/ProfilePage', label: 'Profile', icon: }, //Will be changed to dynamic log in/log out button dependant on user - // { to: '/signin', label: 'Sign In', icon: }, + // { to: '/SignIn', label: 'Sign In', icon: }, ] return ( @@ -46,7 +46,7 @@ export default function Header() { ) : ( (isActive ? 'is-active' : undefined)} > diff --git a/src/components/pageheading.tsx b/src/components/PageHeading.tsx similarity index 92% rename from src/components/pageheading.tsx rename to src/components/PageHeading.tsx index c331241..e88d771 100644 --- a/src/components/pageheading.tsx +++ b/src/components/PageHeading.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { ArrowLeft } from 'lucide-react' import { useNavigate } from 'react-router-dom' -import '../css/pageheading.css' +import '../css/PageHeading.css' type PageHeadingProps = { title: string @@ -12,7 +12,7 @@ type PageHeadingProps = { export default function PageHeading({ title, actions, showBack = true }: PageHeadingProps) { const navigate = useNavigate() - const handleBack = () => { + const HandleBack = () => { const historyIndex = window.history.state?.idx if (typeof historyIndex === 'number' && historyIndex > 0) { navigate(-1) @@ -30,7 +30,7 @@ export default function PageHeading({ title, actions, showBack = true }: PageHea type="button" aria-label="Go back" title="Go back" - onClick={handleBack} + onClick={HandleBack} >

) -} \ No newline at end of file +} diff --git a/src/components/passivegamecard.tsx b/src/components/PassiveGameCard.tsx similarity index 87% rename from src/components/passivegamecard.tsx rename to src/components/PassiveGameCard.tsx index 978bff9..d879e4c 100644 --- a/src/components/passivegamecard.tsx +++ b/src/components/PassiveGameCard.tsx @@ -1,4 +1,4 @@ -import '../css/passivegamecard.css' +import '../css/PassiveGameCard.css' import Card from '@mui/material/Card' import CardContent from '@mui/material/CardContent' import CardMedia from '@mui/material/CardMedia' @@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography' import CardActionArea from '@mui/material/CardActionArea' import { Link } from 'react-router-dom' import { useLayoutEffect, useRef, useState } from 'react' -import type { PassiveGameData } from '../types/game' +import type { PassiveGameData } from '../types/Game' type Props = { game: PassiveGameData @@ -24,13 +24,13 @@ export default function PassiveGameCard({ game, rank }: Props) { const title = titleContainer?.firstElementChild if (!(title instanceof HTMLElement) || !titleContainer) return - const updateOverflow = () => { + const UpdateOverflow = () => { setIsTitleOverflowing(title.scrollHeight > titleContainer.clientHeight + 1) } - updateOverflow() + UpdateOverflow() if (typeof ResizeObserver === 'undefined') return - const resizeObserver = new ResizeObserver(updateOverflow) + const resizeObserver = new ResizeObserver(UpdateOverflow) resizeObserver.observe(titleContainer) resizeObserver.observe(title) @@ -83,13 +83,13 @@ The section that has "slice(0,1)" only exists for the time being to allow develo and compare multiples of the same object to each other, if needed. Change the "1" to alter amount of PassiveGameCards that are shown.*/ /* The below may be inserted under the imports part of a page. This is a temporary hard-coded method for the first delivery of the project. -import games from '../api/mockdata.json' -import bigWalk from '../assets/bigwalk.png' -import barotrauma from '../assets/barotrauma.png' -import silksong from '../assets/silksong.png' -import slayTheSpire from '../assets/slaythespire.png' -import PassiveGameCard from '../components/passivegamecard' -import WishlistGameCard from '../components/wishlistgamecard' +import games from '../api/MockData.json' +import bigWalk from '../assets/BigWalk.png' +import barotrauma from '../assets/Barotrauma.png' +import silksong from '../assets/Silksong.png' +import slayTheSpire from '../assets/SlayTheSpire.png' +import PassiveGameCard from '../components/PassiveGameCard' +import WishlistGameCard from '../components/WishlistGameCard' const imagesById: Record = { 1: bigWalk, diff --git a/src/components/SignIn&UpCard.tsx b/src/components/SignIn&UpCard.tsx deleted file mode 100644 index 843970b..0000000 --- a/src/components/SignIn&UpCard.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { styled } from '@mui/material/styles' -import { createTheme } from '@mui/material/styles' -import MuiCard from '@mui/material/Card' - -export const authTheme = createTheme({ - palette: { - primary: { - main: '#27b722', - light: '#63ce5c', - dark: '#1b8317', - contrastText: '#000000', - }, - }, -}) - -export const SignInAndUpCard = styled(MuiCard)(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - alignSelf: 'center', - width: 'min(calc(100% - 3rem), 450px)', - padding: theme.spacing(4), - gap: theme.spacing(2), - margin: 'auto', - borderColor: 'var(--secondary-component-color)', - '& a': { - color: theme.palette.primary.main, - '&:hover': { - color: theme.palette.primary.dark, - }, - }, - boxShadow: - '0px 5px 15px 0px var(--box-shadow-color), 0px 15px 35px -5px var(--box-shadow-color)', - [theme.breakpoints.up('sm')]: { - width: '450px', - }, - ...theme.applyStyles('dark', { - boxShadow: - '0px 5px 15px 0px var(--box-shadow-color), 0px 15px 35px -5px var(--box-shadow-color)', - }), -})) diff --git a/src/components/SignInAndUpCard.tsx b/src/components/SignInAndUpCard.tsx new file mode 100644 index 0000000..ebe9a13 --- /dev/null +++ b/src/components/SignInAndUpCard.tsx @@ -0,0 +1,7 @@ +import { Card as MuiCard } from '@mui/material' +import type { CardProps } from '@mui/material' +import '../css/SignIn&UpCard.css' + +export const SignInAndUpCard = (props: CardProps) => ( + +) diff --git a/src/components/wishlistgamecard.tsx b/src/components/WishlistGameCard.tsx similarity index 74% rename from src/components/wishlistgamecard.tsx rename to src/components/WishlistGameCard.tsx index 7369420..a62c4e4 100644 --- a/src/components/wishlistgamecard.tsx +++ b/src/components/WishlistGameCard.tsx @@ -1,8 +1,17 @@ -import { Card, CardActionArea, CardActions, CardContent, CardMedia, Chip, Typography, Button } from '@mui/material' -import '../css/wishlistgamecard.css' -import type { PassiveGameData } from '../types/game' -import { removeFromWishlistFrontend } from '../api/wishlistLogicFrontend' -import { getSavedUser } from '../api/userlogic_frontend' +import { + Card, + CardActionArea, + CardActions, + CardContent, + CardMedia, + Chip, + Typography, + Button, +} from '@mui/material' +import '../css/WishlistGameCard.css' +import type { PassiveGameData } from '../types/Game' +import { RemoveFromWishlistFrontend } from '../api/WishlistLogicFrontend' +import { GetSavedUser } from '../api/UserLogicFrontend' import { useState } from 'react' import { Link } from 'react-router-dom' @@ -14,21 +23,21 @@ type wishlistGamecardProps = { export default function WishlistGameCard({ game, onRemoveFromWishlist }: wishlistGamecardProps) { const [isRemoving, setIsRemoving] = useState(false) const [error, setError] = useState(null) - + const isOnSale = game.salePercent !== undefined && game.salePercent > 0 && game.salePercent <= 100 - async function handleRemove() { - const user = getSavedUser() + async function HandleRemove() { + const user = GetSavedUser() if (!user) { setError('You need to sign in to change your wishlist') return } - + setIsRemoving(true) setError(null) try { - await removeFromWishlistFrontend(user.id, game.id) + await RemoveFromWishlistFrontend(user.id, game.id) onRemoveFromWishlist?.(game.id) } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Could not remove game') @@ -36,11 +45,14 @@ export default function WishlistGameCard({ game, onRemoveFromWishlist }: wishlis setIsRemoving(false) } } - + return ( - + {game.name} - -
+
{isOnSale && ( Remove - {error &&

{error}

} + {error &&

{error}

}
) @@ -88,13 +99,13 @@ The section that has "slice(0,1)" only exists for the time being to allow develo and compare multiples of the same object to each other, if needed. Change the "1" to alter amount of WishlistGameCards that are shown.*/ /* The below may be inserted under the imports part of a page. This is a temporary hard-coded method for the first delivery of the project. -import games from '../api/mockdata.json' -import bigWalk from '../assets/bigwalk.png' -import barotrauma from '../assets/barotrauma.png' -import silksong from '../assets/silksong.png' -import slayTheSpire from '../assets/slaythespire.png' -import PassiveGameCard from '../components/passivegamecard' -import WishlistGameCard from '../components/wishlistgamecard' +import games from '../api/MockData.json' +import bigWalk from '../assets/BigWalk.png' +import barotrauma from '../assets/Barotrauma.png' +import silksong from '../assets/Silksong.png' +import slayTheSpire from '../assets/SlayTheSpire.png' +import PassiveGameCard from '../components/PassiveGameCard' +import WishlistGameCard from '../components/WishlistGameCard' const imagesById: Record = { 1: bigWalk, diff --git a/src/context/authcontext.tsx b/src/context/AuthContext.tsx similarity index 53% rename from src/context/authcontext.tsx rename to src/context/AuthContext.tsx index 6d44c44..73512ce 100644 --- a/src/context/authcontext.tsx +++ b/src/context/AuthContext.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { type User, saveUser, clearUser, getSavedUser } from '../api/userlogic_frontend' +import { type User, SaveUser, ClearUser, GetSavedUser } from '../api/UserLogicFrontend' interface AuthContextValue { user: User | null @@ -9,22 +9,26 @@ interface AuthContextValue { const AuthContext = React.createContext(null) export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = React.useState(() => getSavedUser()) + const [user, setUser] = React.useState(() => GetSavedUser()) - const login = (user: User, remember = true) => { - saveUser(user, remember) + const Login = (user: User, remember = true) => { + SaveUser(user, remember) setUser(user) } - const logout = () => { - clearUser() + const Logout = () => { + ClearUser() setUser(null) } - return {children} + return ( + + {children} + + ) } -export function useAuth() { +export function UseAuth() { const context = React.useContext(AuthContext) if (!context) throw new Error('useAuth must be used inside AuthProvider') return context diff --git a/src/css/activegamecard.css b/src/css/ActiveGameCard.css similarity index 100% rename from src/css/activegamecard.css rename to src/css/ActiveGameCard.css diff --git a/src/css/createaccountpage.css b/src/css/CreateAccountPage.css similarity index 100% rename from src/css/createaccountpage.css rename to src/css/CreateAccountPage.css diff --git a/src/css/footer.css b/src/css/Footer.css similarity index 100% rename from src/css/footer.css rename to src/css/Footer.css diff --git a/src/css/gamepage.css b/src/css/GamePage.css similarity index 100% rename from src/css/gamepage.css rename to src/css/GamePage.css diff --git a/src/css/global.css b/src/css/Global.css similarity index 84% rename from src/css/global.css rename to src/css/Global.css index 621db39..1682754 100644 --- a/src/css/global.css +++ b/src/css/Global.css @@ -2,11 +2,11 @@ /* Colors */ --primary-background-color: #ffffff; - --primary-component-color: #7BCF73; - --secondary-component-color: #DAF1D7; + --primary-component-color: #7bcf73; + --secondary-component-color: #daf1d7; --primary-accent-color: #27b722; - --box-shadow-color: #DAF1D7; + --box-shadow-color: #daf1d7; /* Text */ --primary-text-color: #000000; diff --git a/src/css/header.css b/src/css/Header.css similarity index 100% rename from src/css/header.css rename to src/css/Header.css diff --git a/src/css/loginpage.css b/src/css/LoginPage.css similarity index 100% rename from src/css/loginpage.css rename to src/css/LoginPage.css diff --git a/src/css/mainpage.css b/src/css/MainPage.css similarity index 100% rename from src/css/mainpage.css rename to src/css/MainPage.css diff --git a/src/css/pageheading.css b/src/css/PageHeading.css similarity index 100% rename from src/css/pageheading.css rename to src/css/PageHeading.css diff --git a/src/css/passivegamecard.css b/src/css/PassiveGameCard.css similarity index 100% rename from src/css/passivegamecard.css rename to src/css/PassiveGameCard.css diff --git a/src/css/profilepage.css b/src/css/ProfilePage.css similarity index 100% rename from src/css/profilepage.css rename to src/css/ProfilePage.css diff --git a/src/css/searchpage.css b/src/css/SearchPage.css similarity index 99% rename from src/css/searchpage.css rename to src/css/SearchPage.css index 43b5aed..87397d9 100644 --- a/src/css/searchpage.css +++ b/src/css/SearchPage.css @@ -117,4 +117,4 @@ .pageinationSearchTop .MuiPagination-ul { flex-wrap: wrap; justify-content: center; -} \ No newline at end of file +} diff --git a/src/css/SignIn&UpCard.css b/src/css/SignIn&UpCard.css new file mode 100644 index 0000000..61991be --- /dev/null +++ b/src/css/SignIn&UpCard.css @@ -0,0 +1,107 @@ +.sign-in-and-up-card { + display: flex; + flex-direction: column; + align-self: center; + width: min(calc(100% - 3rem), 450px); + padding: 32px; + gap: 16px; + margin: 2rem; + border-color: var(--secondary-component-color); + box-shadow: + 0px 5px 15px 0px var(--box-shadow-color), + 0px 15px 35px -5px var(--box-shadow-color); +} + +.pageShell { + display: flex; + flex-direction: column; + min-height: 100vh; + min-height: 100dvh; +} + +.sign-in-and-up-card a { + color: #27b722; +} + +.sign-in-and-up-card a:hover { + color: #1b8317; +} + +.signup-container { + flex: 1; + min-height: 0; + position: relative; + justify-content: center; +} + +.signup-container { + flex: 1; + min-height: 0; + width: 100%; + position: relative; + justify-content: center; + align-items: center; +} + +.signin-container { + flex: 1; + width: 100%; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: relative; +} + +.pageShell .footerWrapper { + margin-top: auto; +} + +.signup-container::before { + content: ''; + display: block; + position: absolute; + z-index: -1; + inset: 0; + background-image: radial-gradient(ellipse at 50% 50%, hsl(120, 45%, 97%), hsl(0, 0%, 100%)); + background-repeat: no-repeat; +} + +.signup-form { + display: flex; + flex-direction: column; + width: 100%; + gap: 16px; +} + +.signup-form-footer { + display: flex; + flex-direction: column; + gap: 16px; +} + +.signup-footer-text { + text-align: center; +} + +.signin-form { + display: flex; + flex-direction: column; + width: 100%; + gap: 16px; +} + +.signin-form .MuiFormControl-root { + width: 100%; +} + +.signin-form .MuiButton-root { + width: 100%; +} + +@media (min-width: 600px) { + .sign-in-and-up-card { + width: 450px; + } +} diff --git a/src/css/wishlistgamecard.css b/src/css/WishlistGameCard.css similarity index 100% rename from src/css/wishlistgamecard.css rename to src/css/WishlistGameCard.css diff --git a/src/css/wishlistpage.css b/src/css/WishlistPage.css similarity index 99% rename from src/css/wishlistpage.css rename to src/css/WishlistPage.css index 695ebd3..8ac1552 100644 --- a/src/css/wishlistpage.css +++ b/src/css/WishlistPage.css @@ -50,4 +50,4 @@ .wishlistSortControl.MuiFormControl-root { min-width: 200px; } -} \ No newline at end of file +} diff --git a/src/pages/gamepage.tsx b/src/pages/GamePage.tsx similarity index 89% rename from src/pages/gamepage.tsx rename to src/pages/GamePage.tsx index dd0bd4b..37bad2b 100644 --- a/src/pages/gamepage.tsx +++ b/src/pages/GamePage.tsx @@ -1,12 +1,12 @@ -import '../css/gamepage.css' -import Header from '../components/header' -import Footer from '../components/footer' -import ActiveGameCard from '../components/activegamecard' +import '../css/GamePage.css' +import Header from '../components/Header' +import Footer from '../components/Footer' +import ActiveGameCard from '../components/ActiveGameCard' import { useParams } from 'react-router-dom' -import PageHeading from '../components/pageheading' +import PageHeading from '../components/PageHeading' import { useEffect, useState } from 'react' -import { getGameById } from '../api/gameClient' -import type { Game } from '../backend/steam' +import { GetGameById } from '../api/GameClient' +import type { Game } from '../backend/Steam' export default function GamePage() { const { gameId } = useParams() @@ -22,7 +22,7 @@ export default function GamePage() { if (!Number.isSafeInteger(appid)) return - getGameById(appid) + GetGameById(appid) .then((loadedGame) => { if (!cancelled) setLoadedGame({ appid, game: loadedGame, error: null }) }) diff --git a/src/pages/mainpage.tsx b/src/pages/MainPage.tsx similarity index 93% rename from src/pages/mainpage.tsx rename to src/pages/MainPage.tsx index 76513ff..141de0d 100644 --- a/src/pages/mainpage.tsx +++ b/src/pages/MainPage.tsx @@ -1,16 +1,16 @@ -import '../css/mainpage.css' -import Header from '../components/header' -import Footer from '../components/footer' -import PassiveGameCard from '../components/passivegamecard' +import '../css/MainPage.css' +import Header from '../components/Header' +import Footer from '../components/Footer' +import PassiveGameCard from '../components/PassiveGameCard' import { WishlistCard } from '../components/WishlistCard' -import { getGames } from '../api/gameClient' +import { GetGames } from '../api/GameClient' import { - getSavedMainPageGenre, - saveMainPageGenre, - prepareDiscountGenreSearch, + GetSavedMainPageGenre, + SaveMainPageGenre, + PrepareDiscountGenreSearch, type MainPageGenreSection, -} from '../api/mainpageLogicFrontend' -import type { Game } from '../backend/steam' +} from '../api/MainPageLogicFrontend' +import type { Game } from '../backend/Steam' import { Link } from 'react-router-dom' import type { CSSProperties } from 'react' import { useEffect, useMemo, useState } from 'react' @@ -28,7 +28,7 @@ type PageGame = { salePercent: number } -function toPageGame(game: Game): PageGame { +function ToPageGame(game: Game): PageGame { const price = Number(game.price) const salePercent = Number(game.discount) const genres = @@ -70,7 +70,7 @@ function GenreGamesSection({ showSeeMore = false, storageKey, }: GenreGamesSectionProps) { - const [selectedGenre, setSelectedGenre] = useState(() => getSavedMainPageGenre(storageKey)) + const [selectedGenre, setSelectedGenre] = useState(() => GetSavedMainPageGenre(storageKey)) const [firstGameIndex, setFirstGameIndex] = useState(0) const isPhone = useMediaQuery('(max-width: 43.75rem)') const isTwoColumnLayout = useMediaQuery('(max-width: 89.99rem)') @@ -89,16 +89,16 @@ function GenreGamesSection({ const discountLoading = isLoadingDiscounts ?? isLoading useEffect(() => { - if (activeGenre) saveMainPageGenre(storageKey, activeGenre) + if (activeGenre) SaveMainPageGenre(storageKey, activeGenre) }, [activeGenre, storageKey]) - const selectGenre = (genre: string) => { + const SelectGenre = (genre: string) => { setSelectedGenre(genre) setFirstGameIndex(0) } - const openDiscountsInSearch = () => { - prepareDiscountGenreSearch(activeGenre) + const OpenDiscountsInSearch = () => { + PrepareDiscountGenreSearch(activeGenre) } return ( @@ -119,7 +119,7 @@ function GenreGamesSection({ value={activeGenre || null} openOnFocus onChange={(_, genre) => { - selectGenre(genre ?? '') + SelectGenre(genre ?? '') }} disabled={genres.length === 0} renderInput={(params) => ( @@ -131,9 +131,9 @@ function GenreGamesSection({
@@ -206,7 +206,7 @@ export default function MainPage() { useEffect(() => { let cancelled = false - getGames() + GetGames() .then((loadedGames) => { if (!cancelled) setDatabaseGames(loadedGames) }) @@ -226,7 +226,7 @@ export default function MainPage() { } }, []) - const pageGames = useMemo(() => databaseGames.map(toPageGame), [databaseGames]) + const pageGames = useMemo(() => databaseGames.map(ToPageGame), [databaseGames]) const featuredGames = pageGames.slice(0, 3) const genresToFeature = useMemo( () => [...new Set(pageGames.flatMap((game) => game.genre))].sort(), diff --git a/src/pages/profilepage.tsx b/src/pages/ProfilePage.tsx similarity index 80% rename from src/pages/profilepage.tsx rename to src/pages/ProfilePage.tsx index b7ae41e..201fa18 100644 --- a/src/pages/profilepage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,12 +1,12 @@ -import Footer from '../components/footer' -import Header from '../components/header' -import '../css/profilepage.css' +import Footer from '../components/Footer' +import Header from '../components/Header' +import '../css/ProfilePage.css' import Card from '@mui/material/Card' import CardContent from '@mui/material/CardContent' import Avatar from '@mui/material/Avatar' import Divider from '@mui/material/Divider' -import PageHeading from '../components/pageheading' +import PageHeading from '../components/PageHeading' export default function ProfilePage() { return ( @@ -27,9 +27,9 @@ export default function ProfilePage() {

- I am a student at NTNU. I love playing videogames! I like adventuregames, - multiplayer and cute designs. I like to play games with my friends, its a - lot more fun when you dont play alone! + I am a student at NTNU. I love playing videogames! I like + adventuregames, multiplayer and cute designs. I like to play games with + my friends, its a lot more fun when you dont play alone!

diff --git a/src/pages/searchpage.tsx b/src/pages/SearchPage.tsx similarity index 65% rename from src/pages/searchpage.tsx rename to src/pages/SearchPage.tsx index 6f17fb4..2a8422b 100644 --- a/src/pages/searchpage.tsx +++ b/src/pages/SearchPage.tsx @@ -1,32 +1,41 @@ -import '../css/searchpage.css' -import Header from '../components/header' -import Footer from '../components/footer' -import { InputLabel, FormControl, Select, MenuItem, Paper, InputBase, IconButton, Pagination } from '@mui/material' +import '../css/SearchPage.css' +import Header from '../components/Header' +import Footer from '../components/Footer' +import { + InputLabel, + FormControl, + Select, + MenuItem, + Paper, + InputBase, + IconButton, + Pagination, +} from '@mui/material' import { Funnel, SearchIcon } from 'lucide-react' import { useMemo, useState, useEffect } from 'react' -import PageHeading from '../components/pageheading' -import PassiveGameCard from '../components/passivegamecard' -import { getGames } from '../api/gameClient' +import PageHeading from '../components/PageHeading' +import PassiveGameCard from '../components/PassiveGameCard' +import { GetGames } from '../api/GameClient' import { - fetchGenres, - getSavedFiltersSearchpage, - saveFiltersSearchpage, - getQuerySearchpage, - saveQuerySearchpage, - getSortOptionSearchpage, - saveSortOptionSearchpage, - getPageSearchpage, - savePageSearchpage -} from '../api/searchpageLogicFrontend' -import type { Game } from '../backend/steam' + FetchGenres, + GetSavedFiltersSearchpage, + SaveFiltersSearchpage, + GetQuerySearchpage, + SaveQuerySearchpage, + GetSortOptionSearchpage, + SaveSortOptionSearchpage, + GetPageSearchpage, + SavePageSearchpage, +} from '../api/SearchPageLogicFrontend' +import type { Game } from '../backend/Steam' import type { SelectChangeEvent } from '@mui/material' type SortOption = '' | 'discount-desc' | 'price-asc' | 'price-desc' | 'title-asc' const SORT_OPTIONS: SortOption[] = ['', 'discount-desc', 'price-asc', 'price-desc', 'title-asc'] -function getInitialSort(): SortOption { - const saved = getSortOptionSearchpage() +function GetInitialSort(): SortOption { + const saved = GetSortOptionSearchpage() return SORT_OPTIONS.includes(saved as SortOption) ? (saved as SortOption) : '' } @@ -34,17 +43,19 @@ export function SearchPage() { const [games, setGames] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - const [page, setPage] = useState(getPageSearchpage) + const [page, setPage] = useState(GetPageSearchpage) const [genres, setGenres] = useState([]) - const [activeFilters, setActiveFilters] = useState(getSavedFiltersSearchpage) - const [activeSorting, setActiveSorting] = useState(getInitialSort) - const [searchVal, setSearchVal] = useState(getQuerySearchpage) - const [searchQuery, setSearchQuery] = useState(getQuerySearchpage) - const [isFilterActive, setIsFilterActive] = useState(() => activeFilters.length > 0 || activeSorting !== '') + const [activeFilters, setActiveFilters] = useState(GetSavedFiltersSearchpage) + const [activeSorting, setActiveSorting] = useState(GetInitialSort) + const [searchVal, setSearchVal] = useState(GetQuerySearchpage) + const [searchQuery, setSearchQuery] = useState(GetQuerySearchpage) + const [isFilterActive, setIsFilterActive] = useState( + () => activeFilters.length > 0 || activeSorting !== '', + ) // fetches the genres from the DB useEffect(() => { - fetchGenres().then(setGenres).catch(console.error) + FetchGenres().then(setGenres).catch(console.error) }, []) // retures a list of games based on the search query, if there is no query, it returns the original list @@ -67,47 +78,60 @@ export function SearchPage() { const sortedGames = useMemo(() => { const list = [...filteredGames] switch (activeSorting) { - case 'discount-desc': return list.sort((a, b) => Number(b.discount) - Number(a.discount)) - case 'price-asc': return list.sort((a, b) => Number(a.price) - Number(b.price)) - case 'price-desc': return list.sort((a, b) => Number(b.price) - Number(a.price)) - case 'title-asc': return list.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) - default: return list + case 'discount-desc': + return list.sort((a, b) => Number(b.discount) - Number(a.discount)) + case 'price-asc': + return list.sort((a, b) => Number(a.price) - Number(b.price)) + case 'price-desc': + return list.sort((a, b) => Number(b.price) - Number(a.price)) + case 'title-asc': + return list.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) + default: + return list } }, [filteredGames, activeSorting]) // Opens the filter menu - const toggleActive = () => setIsFilterActive((isActive) => !isActive) + const ToggleActive = () => setIsFilterActive((isActive) => !isActive) - const handleSearchSubmit = (e: React.FormEvent) => { + const HandleSearchSubmit = (e: React.FormEvent) => { e.preventDefault() const q = searchVal.trim() setSearchQuery(q) setPage(1) } - const handleFilterChange = (e: SelectChangeEvent) => { + const HandleFilterChange = (e: SelectChangeEvent) => { const { value } = e.target setActiveFilters(typeof value === 'string' ? value.split(',') : value) setPage(1) } - const handleSortChange = (e: SelectChangeEvent) => { + const HandleSortChange = (e: SelectChangeEvent) => { setActiveSorting(e.target.value as SortOption) setPage(1) } // These useEffects save the current filters, query and sorting option in session storage // so that when the user goes back to the searchpage they are kept. - useEffect(() => { saveFiltersSearchpage(activeFilters) }, [activeFilters]) - useEffect(() => { saveSortOptionSearchpage(activeSorting) }, [activeSorting]) - useEffect(() => { saveQuerySearchpage(searchQuery) }, [searchQuery]) - useEffect(() => { savePageSearchpage(page) }, [page]) + useEffect(() => { + SaveFiltersSearchpage(activeFilters) + }, [activeFilters]) + useEffect(() => { + SaveSortOptionSearchpage(activeSorting) + }, [activeSorting]) + useEffect(() => { + SaveQuerySearchpage(searchQuery) + }, [searchQuery]) + useEffect(() => { + SavePageSearchpage(page) + }, [page]) // Loades the games useEffect(() => { let cancelled = false - getGames() + GetGames() .then((loadedGames) => { if (!cancelled) setGames(loadedGames) }) @@ -138,29 +162,26 @@ export function SearchPage() {
- + setSearchVal(e.target.value)} /> - - - + + + - - {isFilterActive && ( @@ -170,14 +191,24 @@ export function SearchPage() { @@ -187,7 +218,7 @@ export function SearchPage() { labelId="filter-by-label" multiple value={activeFilters} - onChange={handleFilterChange} + onChange={HandleFilterChange} label="Filter by genre" renderValue={(selected) => selected.length <= 1 @@ -196,7 +227,7 @@ export function SearchPage() { } > {genres.map((genre) => ( - + {genre} ))} diff --git a/src/pages/SignIn.tsx b/src/pages/SignIn.tsx new file mode 100644 index 0000000..3af3fee --- /dev/null +++ b/src/pages/SignIn.tsx @@ -0,0 +1,158 @@ +import * as React from 'react' +import { + Alert, + Box, + Button, + Checkbox, + CssBaseline, + FormControlLabel, + Divider, + FormLabel, + FormControl, + TextField, + Stack, + Typography, +} from '@mui/material' +import { NavLink } from 'react-router-dom' +import { ThemeProvider } from '@mui/material/styles' +import Header from '../components/Header' +import Footer from '../components/Footer' +import { useNavigate } from 'react-router-dom' +import AuthTheme from '../components/AuthTheme' +import { SignInAndUpCard } from '../components/SignInAndUpCard' +import PageHeading from '../components/PageHeading' +import { SigninUser } from '../api/UserLogicFrontend' +import { UseAuth } from '../context/AuthContext' + +export default function SignInPage() { + const [EmailError, SetEmailError] = React.useState(false) + const [EmailErrorMessage, SetEmailErrorMessage] = React.useState('') + const [PasswordError, SetPasswordError] = React.useState(false) + const [PasswordErrorMessage, SetPasswordErrorMessage] = React.useState('') + const [FormError, SetFormError] = React.useState('') + const [Submitting, SetSubmitting] = React.useState(false) + const { login } = UseAuth() + const Navigate = useNavigate() + + const HandleSubmit = async (event: React.SubmitEvent) => { + event.preventDefault() + SetFormError('') + + const Data = new FormData(event.currentTarget) + const Email = String(Data.get('email') ?? '').trim() + const Password = String(Data.get('password') ?? '') + const Remember = Data.get('remember') !== null + + if (!ValidateInputs(Email, Password)) return + + SetSubmitting(true) + try { + const User = await SigninUser(Email, Password) + login(User, Remember) + Navigate('/') + } catch (error) { + SetFormError(error instanceof Error ? error.message : 'Something went wrong.') + } finally { + SetSubmitting(false) + } + } + + const ValidateInputs = (email: string, password: string) => { + let isValid = true + + if (!/\S+@\S+\.\S+/.test(email)) { + SetEmailError(true) + SetEmailErrorMessage('Please enter a valid email address.') + isValid = false + } else { + SetEmailError(false) + SetEmailErrorMessage('') + } + + if (!password || password.length < 6) { + SetPasswordError(true) + SetPasswordErrorMessage('Password must be at least 6 characters long.') + isValid = false + } else { + SetPasswordError(false) + SetPasswordErrorMessage('') + } + + return isValid + } + + return ( +
+
+ + + + + + + {FormError && {FormError}} + Email + + + Password + + + + } + label="Remember me" + /> + + + or + + + Don't have an account?{' '} + Sign up + + + + + +
+
+ ) +} diff --git a/src/pages/SignUp.tsx b/src/pages/SignUp.tsx new file mode 100644 index 0000000..cb5e975 --- /dev/null +++ b/src/pages/SignUp.tsx @@ -0,0 +1,209 @@ +import * as React from 'react' +import { + Alert, + Stack, + Box, + Button, + CssBaseline, + FormLabel, + FormControl, + TextField, + FormControlLabel, + Checkbox, +} from '@mui/material' +import { ThemeProvider } from '@mui/material/styles' +import { useNavigate } from 'react-router-dom' +import Header from '../components/Header' +import Footer from '../components/Footer' +import PageHeading from '../components/PageHeading' +import { SignupUser } from '../api/UserLogicFrontend' +import { UseAuth } from '../context/AuthContext' +import AuthTheme from '../components/AuthTheme' +import { SignInAndUpCard } from '../components/SignInAndUpCard' + +export default function SignUpPage() { + const [UsernameError, SetUsernameError] = React.useState(false) + const [UsernameErrorMessage, SetUsernameErrorMessage] = React.useState('') + const [EmailError, SetEmailError] = React.useState(false) + const [EmailErrorMessage, SetEmailErrorMessage] = React.useState('') + const [PasswordError, SetPasswordError] = React.useState(false) + const [PasswordErrorMessage, SetPasswordErrorMessage] = React.useState('') + const [ConfirmError, SetConfirmError] = React.useState(false) + const [ConfirmErrorMessage, SetConfirmErrorMessage] = React.useState('') + const [FormError, SetFormError] = React.useState('') + const [Submitting, SetSubmitting] = React.useState(false) + const { login } = UseAuth() + const Navigate = useNavigate() + + const ValidateInputs = ( + username: string, + email: string, + password: string, + checkPassword: string, + ) => { + let isValid = true + + if (!username) { + SetUsernameError(true) + SetUsernameErrorMessage('Please enter a valid username.') + isValid = false + } else { + SetUsernameError(false) + SetUsernameErrorMessage('') + } + + if (!/\S+@\S+\.\S+/.test(email)) { + SetEmailError(true) + SetEmailErrorMessage('Please enter a valid email address.') + isValid = false + } else { + SetEmailError(false) + SetEmailErrorMessage('') + } + + if (!password || password.length < 6) { + SetPasswordError(true) + SetPasswordErrorMessage('Password must be at least 6 characters long.') + isValid = false + } else { + SetPasswordError(false) + SetPasswordErrorMessage('') + } + + if (password !== checkPassword) { + SetConfirmError(true) + SetConfirmErrorMessage('Passwords do not match.') + isValid = false + } else { + SetConfirmError(false) + SetConfirmErrorMessage('') + } + + return isValid + } + + const HandleSubmit = async (event: React.SubmitEvent) => { + event.preventDefault() + SetFormError('') + + const Data = new FormData(event.currentTarget) + const Username = String(Data.get('username') ?? '').trim() + const Email = String(Data.get('email') ?? '').trim() + const Password = String(Data.get('password') ?? '') + const CheckPassword = String(Data.get('checkPassword') ?? '') + const Remember = Data.get('remember') !== null + + if (!ValidateInputs(Username, Email, Password, CheckPassword)) return + + SetSubmitting(true) + try { + const User = await SignupUser(Username, Email, Password) + login(User, Remember) + Navigate('/') + } catch (error) { + SetFormError(error instanceof Error ? error.message : 'Something went wrong.') + } finally { + SetSubmitting(false) + } + } + + return ( +
+
+ + + + + + + {FormError && {FormError}} + + Email + + + + Username + + + + Password + + + + Re-enter Password + + + + } + label="Remember me" + /> + + + + + +
+
+ ) +} diff --git a/src/pages/wishlistpage.tsx b/src/pages/WishlistPage.tsx similarity index 56% rename from src/pages/wishlistpage.tsx rename to src/pages/WishlistPage.tsx index 9415eb6..4ee338c 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/WishlistPage.tsx @@ -1,125 +1,156 @@ -import '../css/wishlistpage.css' -import Header from '../components/header' -import Footer from '../components/footer' -import WishlistGameCard from '../components/wishlistgamecard' +import '../css/WishlistPage.css' +import Header from '../components/Header' +import Footer from '../components/Footer' +import WishlistGameCard from '../components/WishlistGameCard' import { FormControl, Select, MenuItem, InputLabel, Pagination } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState, useEffect, useMemo } from 'react' -import type { Game } from '../backend/steam' +import type { Game } from '../backend/Steam' -import PageHeading from '../components/pageheading' -import { fetchWishlistFrontend, saveSortOptionWishlist, getSortOptionWishlist } from '../api/wishlistLogicFrontend' +import PageHeading from '../components/PageHeading' +import { + FetchWishlistFrontend, + SaveSortOptionWishlist, + GetSortOptionWishlist, +} from '../api/WishlistLogicFrontend' import { WishlistCard } from '../components/WishlistCard' -import { useAuth } from '../context/authcontext' +import { UseAuth } from '../context/AuthContext' type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' | 'title-asc' -const SORT_OPTIONS: SortOption[] = ['discount-desc', 'discount-asc', 'price-asc', 'price-desc', 'title-asc'] -function getInitialSort(): SortOption { - const saved = getSortOptionWishlist() +const SORT_OPTIONS: SortOption[] = [ + 'discount-desc', + 'discount-asc', + 'price-asc', + 'price-desc', + 'title-asc', +] +function GetInitialSort(): SortOption { + const saved = GetSortOptionWishlist() return SORT_OPTIONS.includes(saved as SortOption) ? (saved as SortOption) : 'discount-desc' } export default function WishlistPage() { - const [sortBy, setSortBy] = useState(getInitialSort) - const [wishlistGames, setWishlistGames] = useState([]) - const [error, setError] = useState(null) - const [isLoading, setIsLoading] = useState(true) + const [sortBy, setSortBy] = useState(GetInitialSort) + const { user } = UseAuth() + const [wishlistState, setWishlistState] = useState<{ + user: typeof user + games: Game[] + error: string | null + }>({ user: null, games: [], error: null }) const [page, setPage] = useState(1) - const { user } = useAuth() - const userId = user?.id - - // Loades the games useEffect(() => { - if(userId == null){ - setWishlistGames([]) - setIsLoading(false) - return undefined - } + if (user == null) return undefined let cancelled = false - setIsLoading(true) - setError(null) - fetchWishlistFrontend(userId) + FetchWishlistFrontend(user.id) .then((loadedGames) => { - if (!cancelled) setWishlistGames(loadedGames) + if (!cancelled) setWishlistState({ user, games: loadedGames, error: null }) }) .catch((err: unknown) => { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Could not load games') + setWishlistState({ + user, + games: [], + error: err instanceof Error ? err.message : 'Could not load games', + }) } }) - .finally(() => { - if (!cancelled) setIsLoading(false) - }) return () => { cancelled = true } - }, [userId]) + }, [user]) + + const wishlistGames = useMemo( + () => (wishlistState.user === user ? wishlistState.games : []), + [wishlistState, user], + ) + const error = wishlistState.user === user ? wishlistState.error : null + const isLoading = user != null && wishlistState.user !== user // Takes the list of wishlisted games and sorts them, sorts when the page is loaded const sortedGames = useMemo(() => { const list = [...wishlistGames] - const num = (v: unknown) => Number(v) || 0 - + const Num = (v: unknown) => Number(v) || 0 + switch (sortBy) { - case 'discount-asc': return list.sort((a, b) => num(a.discount) - num(b.discount)) - case 'price-asc': return list.sort((a, b) => num(a.price) - num(b.price)) - case 'price-desc': return list.sort((a, b) => num(b.price) - num(a.price)) - case 'title-asc': return list.sort((a, b) => a.name.localeCompare(b.name)) - case 'discount-desc': return list.sort((a, b) => num(b.discount) - num(a.discount)) - default: return list + case 'discount-asc': + return list.sort((a, b) => Num(a.discount) - Num(b.discount)) + case 'price-asc': + return list.sort((a, b) => Num(a.price) - Num(b.price)) + case 'price-desc': + return list.sort((a, b) => Num(b.price) - Num(a.price)) + case 'title-asc': + return list.sort((a, b) => a.name.localeCompare(b.name)) + case 'discount-desc': + return list.sort((a, b) => Num(b.discount) - Num(a.discount)) + default: + return list } }, [wishlistGames, sortBy]) - - useEffect(() => { saveSortOptionWishlist(sortBy) }, [sortBy]) - + + useEffect(() => { + SaveSortOptionWishlist(sortBy) + }, [sortBy]) + // Decides games per page and makes a constant for the pagination const gamesPerPage = 5 const pageCount = Math.max(1, Math.ceil(sortedGames.length / gamesPerPage)) const currentPage = Math.min(page, pageCount) - + const visibleGames = useMemo(() => { const start = (currentPage - 1) * gamesPerPage return sortedGames.slice(start, start + gamesPerPage) }, [sortedGames, currentPage]) - const handleSortChange = (e: SelectChangeEvent) => { + const HandleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) setPage(1) } - const handleRemoved = (gameId: number) => { - setWishlistGames((games) => games.filter((game) => game.appid !== gameId)) + const HandleRemoved = (gameId: number) => { + setWishlistState((current) => ({ + ...current, + games: current.games.filter((game) => game.appid !== gameId), + })) } - return (
+ Sort by } - /> + /> + /> {error &&

Could not load games: {error}

} {isLoading ? ( @@ -153,13 +184,13 @@ export default function WishlistPage() { image: game.img, salePercent: Number.isFinite(salePercent) ? salePercent : 0, }} - onRemoveFromWishlist={handleRemoved} + onRemoveFromWishlist={HandleRemoved} /> ) })}
)} - + + />
) diff --git a/src/pages/signin.tsx b/src/pages/signin.tsx deleted file mode 100644 index 34e2924..0000000 --- a/src/pages/signin.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import * as React from 'react' -import { - Alert, - Box, - Button, - Checkbox, - CssBaseline, - FormControlLabel, - Divider, - FormLabel, - FormControl, - TextField, - Stack, - Typography, -} from '@mui/material' -import { NavLink } from 'react-router-dom' -import { styled, ThemeProvider } from '@mui/material/styles' -import Header from '../components/header' -import Footer from '../components/footer' -import { useNavigate } from 'react-router-dom' -import { authTheme, SignInAndUpCard } from '../components/SignIn&UpCard' -import PageHeading from '../components/pageheading' -import { signinUser } from '../api/userlogic_frontend' -import { useAuth } from '../context/authcontext' - -const SignInContainer = styled(Stack)(({ theme }) => ({ - flex: 1, - minHeight: 0, - position: 'relative', - '&::before': { - content: '""', - display: 'block', - position: 'absolute', - zIndex: -1, - inset: 0, - backgroundImage: - 'radial-gradient(ellipse at 50% 50%, hsl(120, 45%, 97%), hsl(0, 0%, 100%))', - backgroundRepeat: 'no-repeat', - ...theme.applyStyles('dark', { - backgroundImage: - 'radial-gradient(at 50% 50%, hsla(210, 100%, 16%, 0.5), hsl(220, 30%, 5%))', - }), - }, -})) - -export default function SignInPage() { - const [emailError, setEmailError] = React.useState(false) - const [emailErrorMessage, setEmailErrorMessage] = React.useState('') - const [passwordError, setPasswordError] = React.useState(false) - const [passwordErrorMessage, setPasswordErrorMessage] = React.useState('') - const [formError, setFormError] = React.useState('') - const [submitting, setSubmitting] = React.useState(false) - const { login } = useAuth() - const navigate = useNavigate() - - const handleSubmit = async (event: React.SubmitEvent) => { - event.preventDefault() - setFormError('') - - const data = new FormData(event.currentTarget) - const email = String(data.get('email') ?? '').trim() - const password = String(data.get('password') ?? '') - const remember = data.get('remember') !== null - - if (!validateInputs(email, password)) return - - setSubmitting(true) - try { - const user = await signinUser(email, password) - login(user, remember) - navigate('/') - } catch (error) { - setFormError(error instanceof Error ? error.message : 'Something went wrong.') - } finally { - setSubmitting(false) - } - } - - const validateInputs = (email: string, password: string) => { - let isValid = true - - if (!/\S+@\S+\.\S+/.test(email)) { - setEmailError(true) - setEmailErrorMessage('Please enter a valid email address.') - isValid = false - } else { - setEmailError(false) - setEmailErrorMessage('') - } - - if (!password || password.length < 6) { - setPasswordError(true) - setPasswordErrorMessage('Password must be at least 6 characters long.') - isValid = false - } else { - setPasswordError(false) - setPasswordErrorMessage('') - } - - return isValid - } - - return ( -
-
- - - - - - - {formError && {formError}} - Email - - - Password - - - - } - label="Remember me" - /> - - - or - - - Don't have an account?{' '} - Sign up - - - - - -
-
- ) -} diff --git a/src/pages/signup.tsx b/src/pages/signup.tsx deleted file mode 100644 index ddda9f2..0000000 --- a/src/pages/signup.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import * as React from 'react' -import { - Alert, - Box, - Button, - CssBaseline, - FormLabel, - FormControl, - TextField, - Stack, - FormControlLabel, - Checkbox, -} from '@mui/material' -import { styled, ThemeProvider } from '@mui/material/styles' -import { useNavigate } from 'react-router-dom' -import Header from '../components/header' -import Footer from '../components/footer' -import { authTheme, SignInAndUpCard } from '../components/SignIn&UpCard' -import PageHeading from '../components/pageheading' -import { signupUser } from '../api/userlogic_frontend' -import { useAuth } from '../context/authcontext' - -const SignUpContainer = styled(Stack)(({ theme }) => ({ - flex: 1, - minHeight: 0, - position: 'relative', - '&::before': { - content: '""', - display: 'block', - position: 'absolute', - zIndex: -1, - inset: 0, - backgroundImage: - 'radial-gradient(ellipse at 50% 50%, hsl(120, 45%, 97%), hsl(0, 0%, 100%))', - backgroundRepeat: 'no-repeat', - ...theme.applyStyles('dark', { - backgroundImage: - 'radial-gradient(at 50% 50%, hsla(210, 100%, 16%, 0.5), hsl(220, 30%, 5%))', - }), - }, -})) - -export default function SignUpPage() { - const [usernameError, setUsernameError] = React.useState(false) - const [usernameErrorMessage, setUsernameErrorMessage] = React.useState('') - const [emailError, setEmailError] = React.useState(false) - const [emailErrorMessage, setEmailErrorMessage] = React.useState('') - const [passwordError, setPasswordError] = React.useState(false) - const [passwordErrorMessage, setPasswordErrorMessage] = React.useState('') - const [confirmError, setConfirmError] = React.useState(false) - const [confirmErrorMessage, setConfirmErrorMessage] = React.useState('') - const [formError, setFormError] = React.useState('') - const [submitting, setSubmitting] = React.useState(false) - const { login } = useAuth() - const navigate = useNavigate() - - const validateInputs = ( - username: string, - email: string, - password: string, - check_password: string, - ) => { - let isValid = true - - if (!username) { - setUsernameError(true) - setUsernameErrorMessage('Please enter a valid username.') - isValid = false - } else { - setUsernameError(false) - setUsernameErrorMessage('') - } - - if (!/\S+@\S+\.\S+/.test(email)) { - setEmailError(true) - setEmailErrorMessage('Please enter a valid email address.') - isValid = false - } else { - setEmailError(false) - setEmailErrorMessage('') - } - - if (!password || password.length < 6) { - setPasswordError(true) - setPasswordErrorMessage('Password must be at least 6 characters long.') - isValid = false - } else { - setPasswordError(false) - setPasswordErrorMessage('') - } - - if (password !== check_password) { - setConfirmError(true) - setConfirmErrorMessage('Passwords do not match.') - isValid = false - } else { - setConfirmError(false) - setConfirmErrorMessage('') - } - - return isValid - } - - const handleSubmit = async (event: React.SubmitEvent) => { - event.preventDefault() - setFormError('') - - const data = new FormData(event.currentTarget) - const username = String(data.get('username') ?? '').trim() - const email = String(data.get('email') ?? '').trim() - const password = String(data.get('password') ?? '') - const check_password = String(data.get('check_password') ?? '') - const remember = data.get('remember') !== null - - if (!validateInputs(username, email, password, check_password)) return - - setSubmitting(true) - try { - const user = await signupUser(username, email, password) - login(user, remember) - navigate('/') - } catch (error) { - setFormError(error instanceof Error ? error.message : 'Something went wrong.') - } finally { - setSubmitting(false) - } - } - - return ( -
-
- - - - - - - {formError && {formError}} - - Email - - - - Username - - - - Password - - - - Re-enter Password - - - - } - label="Remember me" - /> - - - - - -
-
- ) -} diff --git a/src/tests/gameClient.test.ts b/src/tests/GameClient.test.ts similarity index 71% rename from src/tests/gameClient.test.ts rename to src/tests/GameClient.test.ts index 5f9b5d5..2dac555 100644 --- a/src/tests/gameClient.test.ts +++ b/src/tests/GameClient.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { Game } from '../backend/steam' -import fetchGamesFromDB, { getGameById, getGames } from '../api/gameClient' +import type { Game } from '../backend/Steam' +import FetchGamesFromDB, { GetGameById, GetGames } from '../api/GameClient' -function mockResponse(body: unknown, ok = true, status = 200): Response { +function MockResponse(body: unknown, ok = true, status = 200): Response { return { ok, status, @@ -11,7 +11,7 @@ function mockResponse(body: unknown, ok = true, status = 200): Response { } as Response } -function stubLocalStorage(values: Map) { +function StubLocalStorage(values: Map) { vi.stubGlobal('localStorage', { get length() { return values.size @@ -37,14 +37,14 @@ describe('fetchGamesFromDB', () => { price: '9.99', initialprice: '9.99', discount: '0', - img: 'https://example.com/header.jpg', + img: 'https://example.com/Header.jpg', genre: 'Action', description: 'A classic shooter.', }, ] - global.fetch = vi.fn().mockResolvedValue(mockResponse({ data: { games } })) + global.fetch = vi.fn().mockResolvedValue(MockResponse({ data: { games } })) - const result = await fetchGamesFromDB(1, 0) + const result = await FetchGamesFromDB(1, 0) expect(global.fetch).toHaveBeenCalledWith( 'http://it2810-10.idi.ntnu.no:3001/', @@ -68,22 +68,22 @@ describe('fetchGamesFromDB', () => { genre: '', description: '', })) - const fetchMock = vi.fn() + const FetchMock = vi.fn() for (let offset = 0; offset < games.length; offset += 500) { - fetchMock.mockResolvedValueOnce( - mockResponse({ data: { games: games.slice(offset, offset + 500) } }), + FetchMock.mockResolvedValueOnce( + MockResponse({ data: { games: games.slice(offset, offset + 500) } }), ) } - fetchMock.mockResolvedValueOnce(mockResponse({ data: { games: [] } })) - global.fetch = fetchMock + FetchMock.mockResolvedValueOnce(MockResponse({ data: { games: [] } })) + global.fetch = FetchMock - const result = await getGames() + const result = await GetGames() expect(result).toHaveLength(2000) expect(result).toEqual(games) - expect(fetchMock).toHaveBeenCalledTimes(5) + expect(FetchMock).toHaveBeenCalledTimes(5) expect( - fetchMock.mock.calls.map(([, options]) => JSON.parse(options.body as string).variables), + FetchMock.mock.calls.map(([, options]) => JSON.parse(options.body as string).variables), ).toEqual([ { limit: 500, offset: 0 }, { limit: 500, offset: 500 }, @@ -101,17 +101,17 @@ describe('fetchGamesFromDB', () => { price: '9.99', initialprice: '9.99', discount: '0', - img: 'https://example.com/header.jpg', + img: 'https://example.com/Header.jpg', genre: 'Action', description: 'A classic shooter.', }, ] const values = new Map() - stubLocalStorage(values) - global.fetch = vi.fn().mockResolvedValue(mockResponse({ data: { games } })) + StubLocalStorage(values) + global.fetch = vi.fn().mockResolvedValue(MockResponse({ data: { games } })) - const firstResult = await fetchGamesFromDB(1, 0) - const secondResult = await fetchGamesFromDB(1, 0) + const firstResult = await FetchGamesFromDB(1, 0) + const secondResult = await FetchGamesFromDB(1, 0) expect(firstResult).toEqual(games) expect(secondResult).toEqual(games) @@ -132,10 +132,10 @@ describe('fetchGamesFromDB', () => { }), ], ]) - stubLocalStorage(values) - global.fetch = vi.fn().mockResolvedValue(mockResponse({ data: { games: [] } })) + StubLocalStorage(values) + global.fetch = vi.fn().mockResolvedValue(MockResponse({ data: { games: [] } })) - await fetchGamesFromDB(1, 0) + await FetchGamesFromDB(1, 0) expect(global.fetch).toHaveBeenCalledTimes(1) }) @@ -152,31 +152,28 @@ describe('fetchGamesFromDB', () => { price: '9.99', initialprice: '9.99', discount: '0', - img: 'https://example.com/header.jpg', + img: 'https://example.com/Header.jpg', genre: 'Action', description: 'A classic shooter.', } it('returns a game from an existing page cache without fetching', async () => { const values = new Map([ - [ - 'games:v1:500:0', - JSON.stringify({ fetchedAt: Date.now(), games: [game] }), - ], + ['games:v1:500:0', JSON.stringify({ fetchedAt: Date.now(), games: [game] })], ]) - stubLocalStorage(values) + StubLocalStorage(values) global.fetch = vi.fn() - await expect(getGameById(game.appid)).resolves.toEqual(game) + await expect(GetGameById(game.appid)).resolves.toEqual(game) expect(global.fetch).not.toHaveBeenCalled() }) it('fetches a missing game from the database and caches it for subsequent calls', async () => { const values = new Map() - stubLocalStorage(values) - global.fetch = vi.fn().mockResolvedValue(mockResponse({ data: { game } })) + StubLocalStorage(values) + global.fetch = vi.fn().mockResolvedValue(MockResponse({ data: { game } })) - await expect(getGameById(game.appid)).resolves.toEqual(game) + await expect(GetGameById(game.appid)).resolves.toEqual(game) expect(global.fetch).toHaveBeenCalledTimes(1) const [, options] = (global.fetch as ReturnType).mock.calls[0] const body = JSON.parse(options.body as string) @@ -187,35 +184,35 @@ describe('fetchGamesFromDB', () => { games: [game], }) - await expect(getGameById(game.appid)).resolves.toEqual(game) + await expect(GetGameById(game.appid)).resolves.toEqual(game) expect(global.fetch).toHaveBeenCalledTimes(1) }) it('returns null when the requested game does not exist', async () => { - stubLocalStorage(new Map()) - global.fetch = vi.fn().mockResolvedValue(mockResponse({ data: { game: null } })) + StubLocalStorage(new Map()) + global.fetch = vi.fn().mockResolvedValue(MockResponse({ data: { game: null } })) - await expect(getGameById(404)).resolves.toBeNull() + await expect(GetGameById(404)).resolves.toBeNull() }) }) it('throws when the HTTP response is not ok', async () => { - global.fetch = vi.fn().mockResolvedValue(mockResponse({}, false, 500)) + global.fetch = vi.fn().mockResolvedValue(MockResponse({}, false, 500)) - await expect(fetchGamesFromDB(1, 0)).rejects.toThrow('Failed to fetch resources: 500') + await expect(FetchGamesFromDB(1, 0)).rejects.toThrow('Failed to fetch resources: 500') }) it('throws the GraphQL error message when errors are present', async () => { global.fetch = vi .fn() - .mockResolvedValue(mockResponse({ errors: [{ message: 'games field not found' }] })) + .mockResolvedValue(MockResponse({ errors: [{ message: 'games field not found' }] })) - await expect(fetchGamesFromDB(1, 0)).rejects.toThrow('games field not found') + await expect(FetchGamesFromDB(1, 0)).rejects.toThrow('games field not found') }) it('throws when no data is returned and there are no errors either', async () => { - global.fetch = vi.fn().mockResolvedValue(mockResponse({})) + global.fetch = vi.fn().mockResolvedValue(MockResponse({})) - await expect(fetchGamesFromDB(1, 0)).rejects.toThrow('No data returned') + await expect(FetchGamesFromDB(1, 0)).rejects.toThrow('No data returned') }) }) diff --git a/src/tests/gamepage.test.tsx b/src/tests/GamePage.test.tsx similarity index 55% rename from src/tests/gamepage.test.tsx rename to src/tests/GamePage.test.tsx index 3b327f3..ada297e 100644 --- a/src/tests/gamepage.test.tsx +++ b/src/tests/GamePage.test.tsx @@ -2,21 +2,21 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes } from 'react-router-dom' -import type { Game } from '../backend/steam' +import type { Game } from '../backend/Steam' -const { getGameById, getGames } = vi.hoisted(() => ({ - getGameById: vi.fn<(appid: number) => Promise>(), - getGames: vi.fn(), +const { GetGameById, GetGames } = vi.hoisted(() => ({ + GetGameById: vi.fn<(appid: number) => Promise>(), + GetGames: vi.fn(), })) -vi.mock('../api/gameClient', () => ({ getGameById, getGames })) -vi.mock('../components/activegamecard', () => ({ +vi.mock('../api/GameClient', () => ({ GetGameById, GetGames })) +vi.mock('../components/ActiveGameCard', () => ({ default: ({ game }: { game: { name: string } }) =>
{game.name}
, })) -import GamePage from '../pages/gamepage' +import GamePage from '../pages/GamePage' -function makeGame(appid: number): Game { +function MakeGame(appid: number): Game { return { appid, name: `Game ${appid}`, @@ -29,20 +29,20 @@ function makeGame(appid: number): Game { } } -vi.mock('../components/header', () => ({ +vi.mock('../components/Header', () => ({ default: () =>
Header
, })) -vi.mock('../components/footer', () => ({ +vi.mock('../components/Footer', () => ({ default: () =>
Footer
, })) -function renderGamePage(path: string) { +function RenderGamePage(path: string) { return render( } /> - } /> + } /> , ) @@ -50,8 +50,8 @@ function renderGamePage(path: string) { describe('GamePage', () => { beforeEach(() => { - getGameById.mockReset() - getGames.mockReset() + GetGameById.mockReset() + GetGames.mockReset() }) afterEach(() => { @@ -59,30 +59,30 @@ describe('GamePage', () => { }) it('fetches and renders only the game matching the route id', async () => { - getGameById.mockResolvedValue(makeGame(42)) + GetGameById.mockResolvedValue(MakeGame(42)) - renderGamePage('/game/42') + RenderGamePage('/game/42') expect(await screen.findAllByText('Game 42')).toHaveLength(2) - expect(getGameById).toHaveBeenCalledExactlyOnceWith(42) - expect(getGames).not.toHaveBeenCalled() + expect(GetGameById).toHaveBeenCalledExactlyOnceWith(42) + expect(GetGames).not.toHaveBeenCalled() }) it('shows loading while the requested game is being fetched', () => { - getGameById.mockReturnValue(new Promise(() => {})) + GetGameById.mockReturnValue(new Promise(() => {})) - renderGamePage('/game/42') + RenderGamePage('/game/42') expect(screen.getAllByText('Loading game...')).toHaveLength(2) - expect(getGameById).toHaveBeenCalledExactlyOnceWith(42) - expect(getGames).not.toHaveBeenCalled() + expect(GetGameById).toHaveBeenCalledExactlyOnceWith(42) + expect(GetGames).not.toHaveBeenCalled() }) it('does not fetch when the route id is invalid', () => { - renderGamePage('/game/not-a-number') + RenderGamePage('/game/not-a-number') expect(screen.getByText('Game not found.')).toBeTruthy() - expect(getGameById).not.toHaveBeenCalled() - expect(getGames).not.toHaveBeenCalled() + expect(GetGameById).not.toHaveBeenCalled() + expect(GetGames).not.toHaveBeenCalled() }) }) diff --git a/src/tests/genres.test.ts b/src/tests/Genres.test.ts similarity index 59% rename from src/tests/genres.test.ts rename to src/tests/Genres.test.ts index e9157ce..a45c2ea 100644 --- a/src/tests/genres.test.ts +++ b/src/tests/Genres.test.ts @@ -1,41 +1,41 @@ // @vitest-environment node import { beforeEach, describe, expect, it, vi } from 'vitest' -const { query } = vi.hoisted(() => ({ query: vi.fn() })) +const { Query } = vi.hoisted(() => ({ Query: vi.fn() })) // Replace the real Postgres pool, so no database (or .env file) is needed -vi.mock('../backend/db_connection.ts', () => ({ pool: { query } })) +vi.mock('../backend/DbConnection.ts', () => ({ pool: { query: Query } })) -import { fetchGenres } from '../backend/genres.ts' +import { FetchGenres } from '../backend/Genres.ts' describe('fetchGenres (backend)', () => { beforeEach(() => { - query.mockReset() + Query.mockReset() }) it('returns the genre strings from the rows as a plain string[]', async () => { - query.mockResolvedValue({ + Query.mockResolvedValue({ rows: [{ genre: 'Action' }, { genre: 'Adventure' }, { genre: 'Indie' }], }) - const result = await fetchGenres() + const result = await FetchGenres() expect(result).toEqual(['Action', 'Adventure', 'Indie']) }) it('returns an empty list when there are no genres', async () => { - query.mockResolvedValue({ rows: [] }) + Query.mockResolvedValue({ rows: [] }) - expect(await fetchGenres()).toEqual([]) + expect(await FetchGenres()).toEqual([]) }) it('queries once, splitting the comma separated genres in SQL', async () => { - query.mockResolvedValue({ rows: [] }) + Query.mockResolvedValue({ rows: [] }) - await fetchGenres() + await FetchGenres() - expect(query).toHaveBeenCalledOnce() - const sql = String(query.mock.calls[0][0]) + expect(Query).toHaveBeenCalledOnce() + const sql = String(Query.mock.calls[0][0]) expect(sql).toContain('string_to_array(genre') expect(sql).toContain('unnest') expect(sql).toContain('DISTINCT') @@ -44,17 +44,17 @@ describe('fetchGenres (backend)', () => { }) it('does not wrap the result (the GraphQL schema expects [String!]!)', async () => { - query.mockResolvedValue({ rows: [{ genre: 'Action' }] }) + Query.mockResolvedValue({ rows: [{ genre: 'Action' }] }) - const result = await fetchGenres() + const result = await FetchGenres() expect(Array.isArray(result)).toBe(true) expect(typeof result[0]).toBe('string') }) it('passes database errors on to the caller', async () => { - query.mockRejectedValue(new Error('connection refused')) + Query.mockRejectedValue(new Error('connection refused')) - await expect(fetchGenres()).rejects.toThrow('connection refused') + await expect(FetchGenres()).rejects.toThrow('connection refused') }) -}) \ No newline at end of file +}) diff --git a/src/tests/mainpage.test.tsx b/src/tests/MainPage.test.tsx similarity index 79% rename from src/tests/mainpage.test.tsx rename to src/tests/MainPage.test.tsx index 811068f..b43110b 100644 --- a/src/tests/mainpage.test.tsx +++ b/src/tests/MainPage.test.tsx @@ -2,16 +2,16 @@ import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter } from 'react-router-dom' -import type { Game } from '../backend/steam' -import { AuthProvider } from '../context/authcontext' +import type { Game } from '../backend/Steam' +import { AuthProvider } from '../context/AuthContext' -const { getGames, useMediaQuery } = vi.hoisted(() => ({ - getGames: vi.fn<() => Promise>(), - useMediaQuery: vi.fn<(query: string) => boolean>(() => false), +const { GetGames, UseMediaQuery } = vi.hoisted(() => ({ + GetGames: vi.fn<() => Promise>(), + UseMediaQuery: vi.fn<(query: string) => boolean>(() => false), })) -vi.mock('../api/gameClient', () => ({ getGames })) -vi.mock('@mui/material/useMediaQuery', () => ({ default: useMediaQuery })) +vi.mock('../api/GameClient', () => ({ GetGames })) +vi.mock('@mui/material/useMediaQuery', () => ({ default: UseMediaQuery })) vi.mock('@mui/material/Autocomplete', () => ({ default: ({ disabled, @@ -42,9 +42,9 @@ vi.mock('@mui/material/Autocomplete', () => ({ ), })) -import MainPage from '../pages/mainpage' +import MainPage from '../pages/MainPage' -function makeGame(appid: number, genre: string, discount = '0', price = '9.99'): Game { +function MakeGame(appid: number, genre: string, discount = '0', price = '9.99'): Game { return { appid, name: `Game ${appid}`, @@ -57,7 +57,7 @@ function makeGame(appid: number, genre: string, discount = '0', price = '9.99'): } } -function renderMainPage() { +function RenderMainPage() { return render( @@ -69,9 +69,9 @@ function renderMainPage() { describe('MainPage', () => { beforeEach(() => { - getGames.mockReset() - useMediaQuery.mockReset() - useMediaQuery.mockReturnValue(false) + GetGames.mockReset() + UseMediaQuery.mockReset() + UseMediaQuery.mockReturnValue(false) sessionStorage.clear() }) @@ -80,14 +80,14 @@ describe('MainPage', () => { }) it('shows loading states while games are being fetched', () => { - getGames.mockReturnValue(new Promise(() => {})) + GetGames.mockReturnValue(new Promise(() => {})) - renderMainPage() + RenderMainPage() expect(screen.getAllByText('Loading games...')).toHaveLength(2) expect(screen.getAllByText('Loading discount games...')).toHaveLength(1) expect(screen.getByText('Loading reviews...')).toBeTruthy() - expect(getGames).toHaveBeenCalledOnce() + expect(GetGames).toHaveBeenCalledOnce() expect( screen .getAllByRole('button', { name: 'Previous' }) @@ -96,21 +96,21 @@ describe('MainPage', () => { }) it('renders featured games, genre carousels, discounts, and reviews from fetched data', async () => { - getGames.mockResolvedValue([ - makeGame(1, 'Action, Adventure', '25'), - makeGame(2, 'Action', '0'), - makeGame(3, 'Action', '110'), - makeGame(4, 'Action', '50'), - makeGame(5, 'Action'), - makeGame(6, 'Action'), - makeGame(7, 'Action'), - makeGame(8, 'Action'), - makeGame(9, 'Action'), - makeGame(10, 'Action'), - makeGame(11, 'Action'), + GetGames.mockResolvedValue([ + MakeGame(1, 'Action, Adventure', '25'), + MakeGame(2, 'Action', '0'), + MakeGame(3, 'Action', '110'), + MakeGame(4, 'Action', '50'), + MakeGame(5, 'Action'), + MakeGame(6, 'Action'), + MakeGame(7, 'Action'), + MakeGame(8, 'Action'), + MakeGame(9, 'Action'), + MakeGame(10, 'Action'), + MakeGame(11, 'Action'), ]) - renderMainPage() + RenderMainPage() expect(await screen.findByRole('heading', { name: 'Top 3 Games' })).toBeTruthy() expect(screen.getByRole('heading', { name: 'Recent reviews' })).toBeTruthy() @@ -143,15 +143,15 @@ describe('MainPage', () => { }) it('changes genre and paginates the carousel in both directions', async () => { - getGames.mockResolvedValue([ - makeGame(1, 'Action'), - makeGame(2, 'Action'), - makeGame(3, 'Action'), - makeGame(4, 'Action'), - makeGame(5, 'Adventure'), + GetGames.mockResolvedValue([ + MakeGame(1, 'Action'), + MakeGame(2, 'Action'), + MakeGame(3, 'Action'), + MakeGame(4, 'Action'), + MakeGame(5, 'Adventure'), ]) - renderMainPage() + RenderMainPage() await screen.findByRole('heading', { name: 'Top 3 Games' }) const regularRow = document.querySelectorAll('.genre-games-row')[0] as HTMLElement @@ -176,9 +176,9 @@ describe('MainPage', () => { it('restores the selected genre independently in both genre sections', async () => { sessionStorage.setItem('GameStart:genreMainpage:top10', JSON.stringify('Action')) sessionStorage.setItem('GameStart:genreMainpage:discounts', JSON.stringify('Adventure')) - getGames.mockResolvedValue([makeGame(1, 'Action'), makeGame(2, 'Adventure', '25')]) + GetGames.mockResolvedValue([MakeGame(1, 'Action'), MakeGame(2, 'Adventure', '25')]) - renderMainPage() + RenderMainPage() await screen.findByRole('heading', { name: 'Top 3 Games' }) const genreSelectors = screen.getAllByLabelText('Choose a genre') as HTMLSelectElement[] @@ -187,15 +187,15 @@ describe('MainPage', () => { }) it('shows two genre games per page when the row has two columns', async () => { - useMediaQuery.mockImplementation((query) => query.includes('89.99rem')) - getGames.mockResolvedValue([ - makeGame(1, 'Action'), - makeGame(2, 'Action'), - makeGame(3, 'Action'), - makeGame(4, 'Action'), + UseMediaQuery.mockImplementation((query) => query.includes('89.99rem')) + GetGames.mockResolvedValue([ + MakeGame(1, 'Action'), + MakeGame(2, 'Action'), + MakeGame(3, 'Action'), + MakeGame(4, 'Action'), ]) - renderMainPage() + RenderMainPage() await screen.findByRole('heading', { name: 'Top 3 Games' }) const topGamesSection = screen @@ -214,14 +214,14 @@ describe('MainPage', () => { }) it('filters discounted games and links to the search page', async () => { - getGames.mockResolvedValue([ - makeGame(1, 'Action', '0'), - makeGame(2, 'Action', '25'), - makeGame(3, 'Action', '101'), - makeGame(4, 'Adventure', '50'), + GetGames.mockResolvedValue([ + MakeGame(1, 'Action', '0'), + MakeGame(2, 'Action', '25'), + MakeGame(3, 'Action', '101'), + MakeGame(4, 'Adventure', '50'), ]) - renderMainPage() + RenderMainPage() await screen.findByRole('heading', { name: 'Top 3 Games' }) const discountSection = screen @@ -234,7 +234,7 @@ describe('MainPage', () => { expect(within(discountRow as HTMLElement).queryByText('Game 3')).toBeNull() expect( within(discountSection).getByRole('link', { name: 'See More' }).getAttribute('href'), - ).toBe('/searchpage') + ).toBe('/SearchPage') fireEvent.change(within(discountSection).getByLabelText('Choose a genre'), { target: { value: 'Adventure' }, @@ -251,9 +251,9 @@ describe('MainPage', () => { }) it('shows empty states when the database returns no games', async () => { - getGames.mockResolvedValue([]) + GetGames.mockResolvedValue([]) - renderMainPage() + RenderMainPage() expect(await screen.findByText('No games found.')).toBeTruthy() expect(screen.getAllByText('No games found for this genre.')).toHaveLength(1) @@ -270,9 +270,9 @@ describe('MainPage', () => { }) it('shows the discount-specific empty state when a genre has games but no discounts', async () => { - getGames.mockResolvedValue([makeGame(1, 'Action')]) + GetGames.mockResolvedValue([MakeGame(1, 'Action')]) - renderMainPage() + RenderMainPage() const discountSection = screen .getByRole('heading', { name: 'Discounts by Genre' }) @@ -286,9 +286,9 @@ describe('MainPage', () => { }) it('shows a useful error when fetching games rejects with an Error', async () => { - getGames.mockRejectedValue(new Error('Database unavailable')) + GetGames.mockRejectedValue(new Error('Database unavailable')) - renderMainPage() + RenderMainPage() expect((await screen.findByRole('alert')).textContent).toBe( 'Could not load games: Database unavailable', @@ -298,9 +298,9 @@ describe('MainPage', () => { }) it('uses a fallback message for non-Error fetch failures', async () => { - getGames.mockRejectedValue('unexpected failure') + GetGames.mockRejectedValue('unexpected failure') - renderMainPage() + RenderMainPage() expect((await screen.findByRole('alert')).textContent).toBe( 'Could not load games: Could not load games', @@ -308,14 +308,14 @@ describe('MainPage', () => { }) it('shows one game per genre row on phone-sized screens', async () => { - useMediaQuery.mockImplementation((query) => query.includes('43.75rem')) - getGames.mockResolvedValue([ - makeGame(1, 'Action', '25'), - makeGame(2, 'Action', '50'), - makeGame(3, 'Action'), + UseMediaQuery.mockImplementation((query) => query.includes('43.75rem')) + GetGames.mockResolvedValue([ + MakeGame(1, 'Action', '25'), + MakeGame(2, 'Action', '50'), + MakeGame(3, 'Action'), ]) - renderMainPage() + RenderMainPage() await screen.findByRole('heading', { name: 'Top 3 Games' }) @@ -326,13 +326,13 @@ describe('MainPage', () => { it('does not update state after the page unmounts during a fetch', async () => { let rejectFetch!: (reason: unknown) => void - getGames.mockReturnValue( + GetGames.mockReturnValue( new Promise((_resolve, reject) => { rejectFetch = reject }), ) - const { unmount } = renderMainPage() + const { unmount } = RenderMainPage() unmount() await act(async () => { @@ -340,6 +340,6 @@ describe('MainPage', () => { await Promise.resolve() }) - expect(getGames).toHaveBeenCalledOnce() + expect(GetGames).toHaveBeenCalledOnce() }) }) diff --git a/src/tests/pageheading.test.tsx b/src/tests/PageHeading.test.tsx similarity index 90% rename from src/tests/pageheading.test.tsx rename to src/tests/PageHeading.test.tsx index f1498a0..9393e14 100644 --- a/src/tests/pageheading.test.tsx +++ b/src/tests/PageHeading.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { Link, MemoryRouter, Route, Routes } from 'react-router-dom' -import PageHeading from '../components/pageheading' +import PageHeading from '../components/PageHeading' describe('PageHeading', () => { afterEach(() => { @@ -43,14 +43,14 @@ describe('PageHeading', () => { it('navigates to the home page when there is no previous history entry', () => { render( - + - Current page + Current page } /> diff --git a/src/tests/passivegamecard.test.tsx b/src/tests/PassiveGameCard.test.tsx similarity index 97% rename from src/tests/passivegamecard.test.tsx rename to src/tests/PassiveGameCard.test.tsx index 0bc7ad0..b20ea99 100644 --- a/src/tests/passivegamecard.test.tsx +++ b/src/tests/PassiveGameCard.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter } from 'react-router-dom' -import PassiveGameCard from '../components/passivegamecard' +import PassiveGameCard from '../components/PassiveGameCard' describe('PassiveGameCard title overflow', () => { let resizeCallback: ResizeObserverCallback | undefined diff --git a/src/tests/SearchPage.test.tsx b/src/tests/SearchPage.test.tsx new file mode 100644 index 0000000..2c298b9 --- /dev/null +++ b/src/tests/SearchPage.test.tsx @@ -0,0 +1,364 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import type { Game } from '../backend/Steam' + +const { GetGames, FetchGenres } = vi.hoisted(() => ({ + GetGames: vi.fn<() => Promise>(), + FetchGenres: vi.fn<() => Promise>(), +})) + +vi.mock('../api/GameClient', () => ({ GetGames })) + +vi.mock('../api/SearchPageLogicFrontend', async () => { + const actual = await vi.importActual( + '../api/SearchPageLogicFrontend', + ) + + return { + ...actual, + FetchGenres, + } +}) + +vi.mock('../components/Header', () => ({ + default: () =>
Header
, +})) + +vi.mock('../components/Footer', () => ({ + default: () =>
Footer
, +})) + +vi.mock('../components/PageHeading', () => ({ + default: ({ title }: { title: string }) =>

{title}

, +})) + +vi.mock('../components/PassiveGameCard', () => ({ + default: ({ game }: { game: { name: string; price: number } }) => ( +
+

{game.name}

+ {game.price.toFixed(2)} +
+ ), +})) + +import { SearchPage } from '../pages/SearchPage' + +function MakeGame(appid: number, overrides: Partial = {}): Game { + return { + appid, + name: `Game ${appid}`, + price: '9.99', + initialprice: '19.99', + discount: '50', + img: `https://example.com/game-${appid}.jpg`, + genre: 'Action', + description: `Description for game ${appid}`, + ...overrides, + } +} + +const doom = MakeGame(1, { name: 'Doom', price: '30', discount: '50', genre: 'Action, Indie' }) +const celeste = MakeGame(2, { + name: 'Celeste', + price: '20', + discount: '10', + genre: 'Indie, Adventure', +}) +const anno = MakeGame(3, { name: 'Anno', price: '40', discount: '0', genre: 'Strategy' }) +const namedGames = [doom, celeste, anno] + +function RenderSearchPage() { + return render( + + + , + ) +} + +function ShownTitles(known: Game[] = namedGames): string[] { + const names = known.map((game) => game.name) + return screen + .queryAllByRole('heading', { hidden: true }) + .map((heading) => heading.textContent ?? '') + .filter((text) => names.includes(text)) +} + +async function RenderWithNamedGames() { + GetGames.mockResolvedValue(namedGames) + RenderSearchPage() + await screen.findByRole('heading', { name: 'Doom' }) +} + +function OpenFilters() { + fireEvent.click(screen.getByRole('button', { name: 'Toggle filters' })) +} + +function Search(text: string) { + const input = screen.getByPlaceholderText('Search here or use filter') + fireEvent.change(input, { target: { value: text } }) + fireEvent.submit(input.closest('form') as HTMLFormElement) +} + +function ChooseSort(optionName: string) { + OpenFilters() + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Sort by' })) + fireEvent.click(screen.getByRole('option', { name: optionName })) +} + +describe('SearchPage', () => { + beforeEach(() => { + GetGames.mockReset() + FetchGenres.mockReset() + FetchGenres.mockResolvedValue(['Action', 'Adventure', 'Indie', 'Strategy']) + sessionStorage.clear() + }) + + afterEach(() => { + cleanup() + }) + + describe('loading and rendering', () => { + it('shows a loading message while the games are being fetched', () => { + GetGames.mockReturnValue(new Promise(() => {})) + + RenderSearchPage() + + expect(screen.getByText('Loading games...')).toBeTruthy() + expect(GetGames).toHaveBeenCalledOnce() + }) + + it('renders the fetched games and paginates the results', async () => { + GetGames.mockResolvedValue( + Array.from({ length: 13 }, (_, index) => MakeGame(index + 1)), + ) + + RenderSearchPage() + + expect(await screen.findByRole('heading', { name: 'Game 1' })).toBeTruthy() + expect(screen.getByRole('heading', { name: 'Game 12' })).toBeTruthy() + expect(screen.queryByRole('heading', { name: 'Game 13' })).toBeNull() + expect(screen.getAllByText('9.99')).toHaveLength(12) + + fireEvent.click(screen.getAllByRole('button', { name: 'Go to page 2' })[0]) + + expect(await screen.findByRole('heading', { name: 'Game 13' })).toBeTruthy() + expect(screen.queryByRole('heading', { name: 'Game 1' })).toBeNull() + }) + + it('shows an empty state when no games are returned', async () => { + GetGames.mockResolvedValue([]) + + RenderSearchPage() + + expect(await screen.findByText('No games found.')).toBeTruthy() + }) + + it('shows an error when loading games fails', async () => { + GetGames.mockRejectedValue(new Error('Database unavailable')) + + RenderSearchPage() + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toBe('Could not load games: Database unavailable') + await waitFor(() => expect(screen.queryByText('Loading games...')).toBeNull()) + }) + + it('toggles the filter controls', async () => { + GetGames.mockResolvedValue([]) + + RenderSearchPage() + + const filterToggle = screen.getByRole('button', { name: 'Toggle filters' }) + expect(filterToggle.getAttribute('aria-pressed')).toBe('false') + expect(screen.queryByText('Sort by:')).toBeNull() + + fireEvent.click(filterToggle) + + expect(filterToggle.getAttribute('aria-pressed')).toBe('true') + expect(screen.getByRole('combobox', { name: 'Sort by' })).toBeTruthy() + expect(screen.getByRole('combobox', { name: 'Filter by genre' })).toBeTruthy() + }) + }) + + describe('search', () => { + it('filters by name on submit, ignoring case', async () => { + await RenderWithNamedGames() + + Search('DOO') + + expect(ShownTitles()).toEqual(['Doom']) + }) + + it('does not filter until the search is submitted', async () => { + await RenderWithNamedGames() + + fireEvent.change(screen.getByPlaceholderText('Search here or use filter'), { + target: { value: 'doom' }, + }) + + expect(ShownTitles()).toHaveLength(3) + }) + + it('works with the search button', async () => { + await RenderWithNamedGames() + + fireEvent.change(screen.getByPlaceholderText('Search here or use filter'), { + target: { value: 'celeste' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'search' })) + + expect(ShownTitles()).toEqual(['Celeste']) + }) + + it('shows an empty state when nothing matches', async () => { + await RenderWithNamedGames() + + Search('zzz') + + expect(screen.getByText('No games found.')).toBeTruthy() + expect(ShownTitles()).toHaveLength(0) + }) + + it('shows all games again when the search is cleared', async () => { + await RenderWithNamedGames() + + Search('doom') + Search('') + + expect(ShownTitles()).toHaveLength(3) + }) + }) + + describe('sorting', () => { + it('sorts by price, low to high', async () => { + await RenderWithNamedGames() + ChooseSort('Price: low to high') + expect(ShownTitles()).toEqual(['Celeste', 'Doom', 'Anno']) + }) + + it('sorts by price, high to low', async () => { + await RenderWithNamedGames() + ChooseSort('Price: high to low') + expect(ShownTitles()).toEqual(['Anno', 'Doom', 'Celeste']) + }) + + it('sorts by discount, high to low', async () => { + await RenderWithNamedGames() + ChooseSort('Discount: high to low') + expect(ShownTitles()).toEqual(['Doom', 'Celeste', 'Anno']) + }) + + it('sorts by title, A to Z', async () => { + await RenderWithNamedGames() + ChooseSort('Title: A to Z') + expect(ShownTitles()).toEqual(['Anno', 'Celeste', 'Doom']) + }) + }) + + describe('genre filter', () => { + async function OpenGenreList() { + OpenFilters() + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Filter by genre' })) + await screen.findByRole('option', { name: 'Indie' }) + } + + it('lists the genres returned by the backend', async () => { + await RenderWithNamedGames() + await OpenGenreList() + + const options = screen.getAllByRole('option').map((option) => option.textContent) + expect(options).toEqual(['Action', 'Adventure', 'Indie', 'Strategy']) + }) + + it('keeps games that contain the selected genre', async () => { + await RenderWithNamedGames() + await OpenGenreList() + + fireEvent.click(screen.getByRole('option', { name: 'Indie' })) + + expect(ShownTitles()).toEqual(['Doom', 'Celeste']) + }) + + it('requires all selected genres', async () => { + await RenderWithNamedGames() + await OpenGenreList() + + fireEvent.click(screen.getByRole('option', { name: 'Action' })) + fireEvent.click(screen.getByRole('option', { name: 'Indie' })) + + expect(ShownTitles()).toEqual(['Doom']) + }) + + it('combines the genre filter with the search', async () => { + await RenderWithNamedGames() + await OpenGenreList() + fireEvent.click(screen.getByRole('option', { name: 'Indie' })) + + Search('celeste') + + expect(ShownTitles()).toEqual(['Celeste']) + }) + }) + + describe('pagination and search', () => { + it('goes back to page 1 when the search changes', async () => { + GetGames.mockResolvedValue( + Array.from({ length: 15 }, (_, index) => MakeGame(index + 1)), + ) + RenderSearchPage() + await screen.findByRole('heading', { name: 'Game 1' }) + + fireEvent.click(screen.getAllByRole('button', { name: 'Go to page 2' })[0]) + await screen.findByRole('heading', { name: 'Game 13' }) + + Search('Game 1') + + expect(await screen.findByRole('heading', { name: 'Game 1' })).toBeTruthy() + expect(screen.getByRole('heading', { name: 'Game 15' })).toBeTruthy() + }) + }) + + describe('sessionStorage', () => { + it('saves the submitted search', async () => { + await RenderWithNamedGames() + + Search('doom') + + await waitFor(() => + expect(sessionStorage.getItem('GameStart:querySearchpage')).toBe('"doom"'), + ) + }) + + it('restores a saved search and opens the filter controls for a saved sort', async () => { + sessionStorage.setItem('GameStart:querySearchpage', JSON.stringify('o')) + sessionStorage.setItem('GameStart:sortOptionSearchpage', JSON.stringify('price-desc')) + + await RenderWithNamedGames() + expect(ShownTitles()).toEqual(['Anno', 'Doom']) + + const input = screen.getByPlaceholderText( + 'Search here or use filter', + ) as HTMLInputElement + expect(input.value).toBe('o') + expect(screen.getByRole('combobox', { name: 'Sort by' })).toBeTruthy() + }) + + it('ignores an invalid saved sort option', async () => { + sessionStorage.setItem('GameStart:sortOptionSearchpage', JSON.stringify('nonsense')) + + await RenderWithNamedGames() + + expect(ShownTitles()).toEqual(['Doom', 'Celeste', 'Anno']) + }) + + it('survives corrupted storage', async () => { + sessionStorage.setItem('GameStart:filtersSearchpage', '{not json') + + await RenderWithNamedGames() + + expect(ShownTitles()).toHaveLength(3) + }) + }) +}) diff --git a/src/tests/setup.ts b/src/tests/Setup.ts similarity index 100% rename from src/tests/setup.ts rename to src/tests/Setup.ts diff --git a/src/tests/steam.test.ts b/src/tests/Steam.test.ts similarity index 80% rename from src/tests/steam.test.ts rename to src/tests/Steam.test.ts index e05c561..ac6504d 100644 --- a/src/tests/steam.test.ts +++ b/src/tests/Steam.test.ts @@ -1,14 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { fetchBulkGames, fillGame, fillAll, fetchGames, type Game } from '../backend/steam' -import { clearPageCache } from '../backend/steam' +import { FetchBulkGames, FillGame, FillAll, FetchGames, type Game } from '../backend/Steam' +import { ClearPageCache } from '../backend/Steam' beforeEach(() => { vi.restoreAllMocks() - clearPageCache() + ClearPageCache() }) // Helper to build a fake fetch Response -function mockResponse(body: unknown, ok = true, status = 200): Response { +function MockResponse(body: unknown, ok = true, status = 200): Response { return { ok, status, @@ -45,9 +45,9 @@ describe('fetchBulkGames', () => { discount: '0', }, } - global.fetch = vi.fn().mockResolvedValue(mockResponse(steamSpyPayload)) + global.fetch = vi.fn().mockResolvedValue(MockResponse(steamSpyPayload)) - const result = await fetchBulkGames(2, 0) + const result = await FetchBulkGames(2, 0) expect(global.fetch).toHaveBeenCalledWith('https://steamspy.com/api.php?request=all&page=0') expect(result).toHaveLength(2) @@ -67,9 +67,9 @@ describe('fetchBulkGames', () => { const steamSpyPayload = { '1': { appid: 1, name: 'Free Game', price: '0', initialprice: '0', discount: '0' }, } - global.fetch = vi.fn().mockResolvedValue(mockResponse(steamSpyPayload)) + global.fetch = vi.fn().mockResolvedValue(MockResponse(steamSpyPayload)) - const result = await fetchBulkGames(10, 0) + const result = await FetchBulkGames(10, 0) expect(result[0].price).toBe('0') expect(result[0].initialprice).toBe('0') @@ -85,9 +85,9 @@ describe('fetchBulkGames', () => { discount: '50', }, } - global.fetch = vi.fn().mockResolvedValue(mockResponse(steamSpyPayload)) + global.fetch = vi.fn().mockResolvedValue(MockResponse(steamSpyPayload)) - const result = await fetchBulkGames(10, 0) + const result = await FetchBulkGames(10, 0) expect(result[0]).toMatchObject({ price: '4.99', @@ -97,18 +97,18 @@ describe('fetchBulkGames', () => { }) it('throws when the SteamSpy request fails', async () => { - global.fetch = vi.fn().mockResolvedValue(mockResponse({}, false, 503)) + global.fetch = vi.fn().mockResolvedValue(MockResponse({}, false, 503)) - await expect(fetchBulkGames(5, 0)).rejects.toThrow('SteamSpy request failed: 503') + await expect(FetchBulkGames(5, 0)).rejects.toThrow('SteamSpy request failed: 503') }) it('returns an empty array when limit is 0', async () => { const steamSpyPayload = { '10': { appid: 10, name: 'Counter-Strike', price: '999' }, } - global.fetch = vi.fn().mockResolvedValue(mockResponse(steamSpyPayload)) + global.fetch = vi.fn().mockResolvedValue(MockResponse(steamSpyPayload)) - const result = await fetchBulkGames(0, 0) + const result = await FetchBulkGames(0, 0) expect(result).toEqual([]) }) @@ -137,7 +137,7 @@ describe('fillGame', () => { data: { steam_appid: 10, short_description: 'A classic shooter.', - header_image: 'https://example.com/header.jpg', + header_image: 'https://example.com/Header.jpg', genres: [ { id: '1', description: 'Action' }, { id: '2', description: 'FPS' }, @@ -145,16 +145,16 @@ describe('fillGame', () => { }, }, } - global.fetch = vi.fn().mockResolvedValue(mockResponse(appDetailsPayload)) + global.fetch = vi.fn().mockResolvedValue(MockResponse(appDetailsPayload)) - const result = await fillGame(bareGame) + const result = await FillGame(bareGame) expect(global.fetch).toHaveBeenCalledWith( 'https://store.steampowered.com/api/appdetails?appids=10', ) expect(result).toEqual({ ...bareGame, - img: 'https://example.com/header.jpg', + img: 'https://example.com/Header.jpg', description: 'A classic shooter.', genre: 'Action, FPS', }) @@ -167,40 +167,40 @@ describe('fillGame', () => { data: { steam_appid: 10, short_description: undefined, - header_image: 'https://example.com/header.jpg', + header_image: 'https://example.com/Header.jpg', }, }, } - global.fetch = vi.fn().mockResolvedValue(mockResponse(appDetailsPayload)) + global.fetch = vi.fn().mockResolvedValue(MockResponse(appDetailsPayload)) - const result = await fillGame(bareGame) + const result = await FillGame(bareGame) expect(result.description).toBe('') expect(result.genre).toBe('') }) it('returns the original game unchanged when the HTTP request fails', async () => { - global.fetch = vi.fn().mockResolvedValue(mockResponse({}, false, 429)) + global.fetch = vi.fn().mockResolvedValue(MockResponse({}, false, 429)) - const result = await fillGame(bareGame) + const result = await FillGame(bareGame) expect(result).toEqual(bareGame) }) it('returns the original game unchanged when success is false', async () => { const appDetailsPayload = { '10': { success: false } } - global.fetch = vi.fn().mockResolvedValue(mockResponse(appDetailsPayload)) + global.fetch = vi.fn().mockResolvedValue(MockResponse(appDetailsPayload)) - const result = await fillGame(bareGame) + const result = await FillGame(bareGame) expect(result).toEqual(bareGame) }) it('returns the original game unchanged when data is missing', async () => { const appDetailsPayload = { '10': { success: true } } - global.fetch = vi.fn().mockResolvedValue(mockResponse(appDetailsPayload)) + global.fetch = vi.fn().mockResolvedValue(MockResponse(appDetailsPayload)) - const result = await fillGame(bareGame) + const result = await FillGame(bareGame) expect(result).toEqual(bareGame) }) @@ -238,7 +238,7 @@ describe('fillAll', () => { global.fetch = vi.fn().mockImplementation((url: string) => { const appid = url.split('appids=')[1] return Promise.resolve( - mockResponse({ + MockResponse({ [appid]: { success: true, data: { @@ -252,7 +252,7 @@ describe('fillAll', () => { ) }) - const result = await fillAll(games, 0) // delayMs 0 keeps the test fast + const result = await FillAll(games, 0) // delayMs 0 keeps the test fast expect(result.map((g) => g.description)).toEqual(['desc-1', 'desc-2']) expect(global.fetch).toHaveBeenCalledTimes(2) @@ -261,7 +261,7 @@ describe('fillAll', () => { it('returns an empty array when given no games', async () => { global.fetch = vi.fn() - const result = await fillAll([], 0) + const result = await FillAll([], 0) expect(result).toEqual([]) expect(global.fetch).not.toHaveBeenCalled() @@ -281,7 +281,7 @@ describe('fetchGames', () => { global.fetch = vi.fn().mockImplementation((url: string) => { if (url.includes('steamspy.com')) { return Promise.resolve( - mockResponse({ + MockResponse({ '10': { appid: 10, name: 'Counter-Strike', @@ -293,13 +293,13 @@ describe('fetchGames', () => { ) } return Promise.resolve( - mockResponse({ + MockResponse({ '10': { success: true, data: { steam_appid: 10, short_description: 'A classic shooter.', - header_image: 'https://example.com/header.jpg', + header_image: 'https://example.com/Header.jpg', genres: [{ id: '1', description: 'Action' }], }, }, @@ -307,7 +307,7 @@ describe('fetchGames', () => { ) }) - const result = await fetchGames(5, 0, 0) + const result = await FetchGames(5, 0, 0) expect(result).toEqual([ { @@ -316,7 +316,7 @@ describe('fetchGames', () => { price: '4.99', initialprice: '9.99', discount: '50', - img: 'https://example.com/header.jpg', + img: 'https://example.com/Header.jpg', genre: 'Action', description: 'A classic shooter.', }, @@ -324,8 +324,8 @@ describe('fetchGames', () => { }) it('propagates an error if the bulk fetch fails', async () => { - global.fetch = vi.fn().mockResolvedValue(mockResponse({}, false, 500)) + global.fetch = vi.fn().mockResolvedValue(MockResponse({}, false, 500)) - await expect(fetchGames(5, 0, 0)).rejects.toThrow('SteamSpy request failed: 500') + await expect(FetchGames(5, 0, 0)).rejects.toThrow('SteamSpy request failed: 500') }) }) diff --git a/src/tests/userlogic_backend.test.ts b/src/tests/UserLogicBackend.test.ts similarity index 61% rename from src/tests/userlogic_backend.test.ts rename to src/tests/UserLogicBackend.test.ts index f92ade2..c050e80 100644 --- a/src/tests/userlogic_backend.test.ts +++ b/src/tests/UserLogicBackend.test.ts @@ -1,38 +1,38 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -vi.mock('../backend/db_connection', () => ({ +vi.mock('../backend/DbConnection', () => ({ pool: { query: vi.fn() }, })) -import { pool } from '../backend/db_connection' -import { signup, signin } from '../backend/userlogic_backend' +import { pool } from '../backend/DbConnection' +import { Signup, Signin } from '../backend/UserLogicBackend' -const query = pool.query as unknown as ReturnType +const Query = pool.query as unknown as ReturnType beforeEach(() => { - query.mockReset() + Query.mockReset() }) describe('signup', () => { it('returns the created user in the API shape', async () => { - query.mockResolvedValue({ + Query.mockResolvedValue({ rows: [{ user_id: 7, user_name: 'steel', email: 'steel@example.com' }], }) - const user = await signup('steel', 'steel@example.com', 'secret1') + const user = await Signup('steel', 'steel@example.com', 'secret1') expect(user).toEqual({ id: 7, email: 'steel@example.com', username: 'steel' }) }) it('inserts username, password and normalized email in the right order', async () => { - query.mockResolvedValue({ + Query.mockResolvedValue({ rows: [{ user_id: 1, user_name: 'Steel', email: 'steel@example.com' }], }) - await signup('Steel', ' Steel@Example.COM ', 'secret1') + await Signup('Steel', ' Steel@Example.COM ', 'secret1') - expect(query).toHaveBeenCalledTimes(1) - const [sql, params] = query.mock.calls[0] + expect(Query).toHaveBeenCalledTimes(1) + const [sql, params] = Query.mock.calls[0] expect(sql).toContain('INSERT INTO users') expect(sql).toContain('RETURNING user_id, user_name, email') expect(params).toEqual(['Steel', 'secret1', 'steel@example.com']) @@ -43,32 +43,32 @@ describe('signup', () => { ['steel', '', 'secret1'], ['steel', 'a@b.no', ''], ])('rejects missing fields (%j, %j, %j) without touching the database', async (u, e, p) => { - await expect(signup(u, e, p)).rejects.toThrow('Email, username and password are required.') - expect(query).not.toHaveBeenCalled() + await expect(Signup(u, e, p)).rejects.toThrow('Email, username and password are required.') + expect(Query).not.toHaveBeenCalled() }) it.each(['user1', 'a@b', '@x.no', 'no-at-sign.com'])( 'rejects the invalid email %j without touching the database', async (email) => { - await expect(signup('steel', email, 'secret1')).rejects.toThrow( + await expect(Signup('steel', email, 'secret1')).rejects.toThrow( 'Please enter a valid email address.', ) - expect(query).not.toHaveBeenCalled() + expect(Query).not.toHaveBeenCalled() }, ) it('turns a unique violation into a friendly message', async () => { - query.mockRejectedValue(Object.assign(new Error('duplicate key value'), { code: '23505' })) + Query.mockRejectedValue(Object.assign(new Error('duplicate key value'), { code: '23505' })) - await expect(signup('steel', 'steel@example.com', 'secret1')).rejects.toThrow( + await expect(Signup('steel', 'steel@example.com', 'secret1')).rejects.toThrow( 'An account with this email already exists.', ) }) it('wraps other database errors', async () => { - query.mockRejectedValue(new Error('connection lost')) + Query.mockRejectedValue(new Error('connection lost')) - await expect(signup('steel', 'steel@example.com', 'secret1')).rejects.toThrow( + await expect(Signup('steel', 'steel@example.com', 'secret1')).rejects.toThrow( 'Could not register user: connection lost', ) }) @@ -76,27 +76,27 @@ describe('signup', () => { describe('signin', () => { it('returns the user when email and password match', async () => { - query.mockResolvedValue({ + Query.mockResolvedValue({ rows: [{ user_id: 3, user_name: 'user1', email: 'user1@email.post' }], }) - const user = await signin(' User1@Email.post ', 'bruker1') + const user = await Signin(' User1@Email.post ', 'bruker1') expect(user).toEqual({ id: 3, email: 'user1@email.post', username: 'user1' }) - expect(query.mock.calls[0][1]).toEqual(['user1@email.post', 'bruker1']) + expect(Query.mock.calls[0][1]).toEqual(['user1@email.post', 'bruker1']) }) it('throws a generic error when no user matches', async () => { - query.mockResolvedValue({ rows: [] }) + Query.mockResolvedValue({ rows: [] }) - await expect(signin('nobody@example.com', 'wrong')).rejects.toThrow( + await expect(Signin('nobody@example.com', 'wrong')).rejects.toThrow( 'Invalid email or password', ) }) it('lets database errors through', async () => { - query.mockRejectedValue(new Error('db down')) + Query.mockRejectedValue(new Error('db down')) - await expect(signin('a@b.no', 'secret1')).rejects.toThrow('db down') + await expect(Signin('a@b.no', 'secret1')).rejects.toThrow('db down') }) -}) \ No newline at end of file +}) diff --git a/src/tests/userlogic_frontend.test.ts b/src/tests/UserLogicFrontend.test.ts similarity index 71% rename from src/tests/userlogic_frontend.test.ts rename to src/tests/UserLogicFrontend.test.ts index 02de193..6d2c542 100644 --- a/src/tests/userlogic_frontend.test.ts +++ b/src/tests/UserLogicFrontend.test.ts @@ -1,23 +1,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -vi.mock('../api/logicconnection', () => ({ gql: vi.fn() })) +vi.mock('../api/LogicConnection', () => ({ Gql: vi.fn() })) -import { gql } from '../api/logicconnection' +import { Gql } from '../api/LogicConnection' import { - signinUser, - signupUser, - saveUser, - getSavedUser, - clearUser, + SigninUser, + SignupUser, + SaveUser, + GetSavedUser, + ClearUser, type User, -} from '../api/userlogic_frontend' +} from '../api/UserLogicFrontend' -const gqlMock = gql as unknown as ReturnType +const GqlMock = Gql as unknown as ReturnType const KEY = 'GameStart:user' const user: User = { id: 1, email: 'steel@example.com', username: 'steel' } -function createStorage(): Storage { +function CreateStorage(): Storage { let store: Record = {} return { get length() { @@ -38,9 +38,9 @@ function createStorage(): Storage { } beforeEach(() => { - gqlMock.mockReset() - vi.stubGlobal('localStorage', createStorage()) - vi.stubGlobal('sessionStorage', createStorage()) + GqlMock.mockReset() + vi.stubGlobal('localStorage', CreateStorage()) + vi.stubGlobal('sessionStorage', CreateStorage()) }) afterEach(() => { @@ -50,32 +50,32 @@ afterEach(() => { describe('signinUser', () => { it('sends the signin mutation with email and password and returns the user', async () => { - gqlMock.mockResolvedValue({ signin: user }) + GqlMock.mockResolvedValue({ signin: user }) - const result = await signinUser('steel@example.com', 'secret1') + const result = await SigninUser('steel@example.com', 'secret1') expect(result).toEqual(user) - const [query, variables] = gqlMock.mock.calls[0] + const [query, variables] = GqlMock.mock.calls[0] expect(query).toContain('signin(email: $email, password: $password)') expect(query).toContain('id email username') expect(variables).toEqual({ email: 'steel@example.com', password: 'secret1' }) }) it('passes server errors on to the caller', async () => { - gqlMock.mockRejectedValue(new Error('Invalid email or password.')) + GqlMock.mockRejectedValue(new Error('Invalid email or password.')) - await expect(signinUser('a@b.no', 'wrong')).rejects.toThrow('Invalid email or password.') + await expect(SigninUser('a@b.no', 'wrong')).rejects.toThrow('Invalid email or password.') }) }) describe('signupUser', () => { it('sends the signup mutation with username, email and password and returns the user', async () => { - gqlMock.mockResolvedValue({ signup: user }) + GqlMock.mockResolvedValue({ signup: user }) - const result = await signupUser('steel', 'steel@example.com', 'secret1') + const result = await SignupUser('steel', 'steel@example.com', 'secret1') expect(result).toEqual(user) - const [query, variables] = gqlMock.mock.calls[0] + const [query, variables] = GqlMock.mock.calls[0] expect(query).toContain('signup(') expect(query).toContain('id email username') expect(variables).toEqual({ @@ -86,9 +86,9 @@ describe('signupUser', () => { }) it('passes server errors on to the caller', async () => { - gqlMock.mockRejectedValue(new Error('An account with this email already exists.')) + GqlMock.mockRejectedValue(new Error('An account with this email already exists.')) - await expect(signupUser('steel', 'steel@example.com', 'secret1')).rejects.toThrow( + await expect(SignupUser('steel', 'steel@example.com', 'secret1')).rejects.toThrow( 'An account with this email already exists.', ) }) @@ -96,23 +96,23 @@ describe('signupUser', () => { describe('saveUser', () => { it('remembers the user in localStorage by default', () => { - saveUser(user) + SaveUser(user) expect(JSON.parse(localStorage.getItem(KEY)!)).toEqual(user) expect(sessionStorage.getItem(KEY)).toBeNull() }) it('uses sessionStorage when remember is false', () => { - saveUser(user, false) + SaveUser(user, false) expect(JSON.parse(sessionStorage.getItem(KEY)!)).toEqual(user) expect(localStorage.getItem(KEY)).toBeNull() }) it('removes an older remembered user when saving a session-only one', () => { - saveUser({ ...user, username: 'old' }, true) + SaveUser({ ...user, username: 'old' }, true) - saveUser(user, false) + SaveUser(user, false) expect(localStorage.getItem(KEY)).toBeNull() expect(JSON.parse(sessionStorage.getItem(KEY)!)).toEqual(user) @@ -124,39 +124,39 @@ describe('saveUser', () => { throw new Error('quota exceeded') }) - expect(() => saveUser(user)).not.toThrow() + expect(() => SaveUser(user)).not.toThrow() expect(log).toHaveBeenCalledWith(expect.stringContaining('quota exceeded')) }) }) describe('getSavedUser', () => { it('returns null when nobody is saved', () => { - expect(getSavedUser()).toBeNull() + expect(GetSavedUser()).toBeNull() }) it('reads a remembered user from localStorage', () => { - saveUser(user, true) + SaveUser(user, true) - expect(getSavedUser()).toEqual(user) + expect(GetSavedUser()).toEqual(user) }) it('reads a session-only user from sessionStorage', () => { - saveUser(user, false) + SaveUser(user, false) - expect(getSavedUser()).toEqual(user) + expect(GetSavedUser()).toEqual(user) }) it('prefers localStorage when both contain a user', () => { localStorage.setItem(KEY, JSON.stringify(user)) sessionStorage.setItem(KEY, JSON.stringify({ ...user, username: 'other' })) - expect(getSavedUser()).toEqual(user) + expect(GetSavedUser()).toEqual(user) }) it('returns null when the stored value is not valid JSON', () => { localStorage.setItem(KEY, '{not json') - expect(getSavedUser()).toBeNull() + expect(GetSavedUser()).toBeNull() }) it('returns null when reading storage throws', () => { @@ -164,7 +164,7 @@ describe('getSavedUser', () => { throw new Error('access denied') }) - expect(getSavedUser()).toBeNull() + expect(GetSavedUser()).toBeNull() }) }) @@ -173,10 +173,10 @@ describe('clearUser', () => { localStorage.setItem(KEY, JSON.stringify(user)) sessionStorage.setItem(KEY, JSON.stringify(user)) - clearUser() + ClearUser() expect(localStorage.getItem(KEY)).toBeNull() expect(sessionStorage.getItem(KEY)).toBeNull() - expect(getSavedUser()).toBeNull() + expect(GetSavedUser()).toBeNull() }) -}) \ No newline at end of file +}) diff --git a/src/tests/WishlistBackend.test.ts b/src/tests/WishlistBackend.test.ts new file mode 100644 index 0000000..4817034 --- /dev/null +++ b/src/tests/WishlistBackend.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { Query } = vi.hoisted(() => ({ Query: vi.fn() })) + +// Replace the real Postgres pool, so no database (or .env file) is needed +vi.mock('../backend/DbConnection.ts', () => ({ pool: { query: Query } })) + +import { AddToWishlist, RemoveFromWishlist } from '../backend/WishlistLogic.ts' + +describe('addToWishlist (backend)', () => { + beforeEach(() => { + Query.mockReset() + }) + + it('inserts the user and game and returns ok with the game id', async () => { + Query.mockResolvedValue({ rows: [], rowCount: 1 }) + + const result = await AddToWishlist(1, 570) + + expect(result).toEqual({ ok: true, gameId: 570 }) + expect(Query).toHaveBeenCalledOnce() + const [sql, params] = Query.mock.calls[0] + expect(String(sql)).toContain('INSERT INTO wishlist') + expect(String(sql)).toContain('user_id') + expect(String(sql)).toContain('game_id') + expect(params).toEqual([1, 570]) + }) + + it('ignores duplicates instead of failing', async () => { + Query.mockResolvedValue({ rows: [], rowCount: 0 }) + + await expect(AddToWishlist(1, 570)).resolves.toEqual({ ok: true, gameId: 570 }) + + expect(String(Query.mock.calls[0][0])).toContain( + 'ON CONFLICT (user_id, game_id) DO NOTHING', + ) + }) + + it('uses placeholders, never building the values into the SQL', async () => { + Query.mockResolvedValue({ rows: [] }) + + await AddToWishlist(7, 4242) + + const sql = String(Query.mock.calls[0][0]) + expect(sql).toContain('$1') + expect(sql).toContain('$2') + expect(sql).not.toContain('4242') + }) + + it.each([ + ['userId', 0, 570, 'addToWishlist: userId can not be empty'], + ['gameId', 1, 0, 'addToWishlist: gameId can not be empty'], + ])( + 'rejects a missing %s without touching the database', + async (_name, userId, gameId, message) => { + await expect(AddToWishlist(userId, gameId)).rejects.toThrow(message) + expect(Query).not.toHaveBeenCalled() + }, + ) + + it('turns a foreign key violation into a friendly error', async () => { + Query.mockRejectedValue(Object.assign(new Error('fk violation'), { code: '23503' })) + + await expect(AddToWishlist(999, 570)).rejects.toThrow( + 'addToWishlist: user or game does not exist', + ) + }) + + it('passes other database errors on unchanged', async () => { + Query.mockRejectedValue(new Error('connection refused')) + + await expect(AddToWishlist(1, 570)).rejects.toThrow('connection refused') + }) + + it('does not hide errors that only look similar (a different error code)', async () => { + Query.mockRejectedValue(Object.assign(new Error('permission denied'), { code: '42501' })) + + await expect(AddToWishlist(1, 570)).rejects.toThrow('permission denied') + }) +}) + +describe('removeFromWishlist (backend)', () => { + beforeEach(() => { + Query.mockReset() + }) + + it('deletes the matching row and returns ok with the game id', async () => { + Query.mockResolvedValue({ rows: [], rowCount: 1 }) + + const result = await RemoveFromWishlist(1, 570) + + expect(result).toEqual({ ok: true, gameId: 570 }) + expect(Query).toHaveBeenCalledOnce() + const [sql, params] = Query.mock.calls[0] + expect(String(sql)).toContain('DELETE FROM wishlist') + expect(String(sql)).toContain('user_id = $1') + expect(String(sql)).toContain('game_id = $2') + expect(params).toEqual([1, 570]) + }) + + it('only deletes one user/Game pair, never the whole table', async () => { + Query.mockResolvedValue({ rows: [], rowCount: 1 }) + + await RemoveFromWishlist(1, 570) + + expect(String(Query.mock.calls[0][0])).toContain('WHERE') + }) + + it('succeeds even if the game was not on the wishlist', async () => { + Query.mockResolvedValue({ rows: [], rowCount: 0 }) + + await expect(RemoveFromWishlist(1, 570)).resolves.toEqual({ ok: true, gameId: 570 }) + }) + + it.each([ + ['userId', 0, 570, 'removeFromWishlist: userId can not be empty'], + ['gameId', 1, 0, 'removeFromWishlist: gameId can not be empty'], + ])( + 'rejects a missing %s without touching the database', + async (_name, userId, gameId, message) => { + await expect(RemoveFromWishlist(userId, gameId)).rejects.toThrow(message) + expect(Query).not.toHaveBeenCalled() + }, + ) + + it('passes database errors on to the caller', async () => { + Query.mockRejectedValue(new Error('connection refused')) + + await expect(RemoveFromWishlist(1, 570)).rejects.toThrow('connection refused') + }) +}) diff --git a/src/tests/wishlistpage.test.tsx b/src/tests/WishlistPage.test.tsx similarity index 60% rename from src/tests/wishlistpage.test.tsx rename to src/tests/WishlistPage.test.tsx index 3e24190..a487f9b 100644 --- a/src/tests/wishlistpage.test.tsx +++ b/src/tests/WishlistPage.test.tsx @@ -2,17 +2,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - addToWishlistFrontend, - fetchWishlistFrontend, - removeFromWishlistFrontend, - toggleGameWishlist, -} from '../api/wishlistLogicFrontend' + AddToWishlistFrontend, + FetchWishlistFrontend, + RemoveFromWishlistFrontend, + ToggleGameWishlist, +} from '../api/WishlistLogicFrontend' -const fetchMock = vi.fn() +const FetchMock = vi.fn() // Makes fetch answer like a GraphQL server -function respondWith(body: unknown, options: { ok?: boolean; status?: number } = {}) { - fetchMock.mockResolvedValue({ +function RespondWith(body: unknown, options: { ok?: boolean; status?: number } = {}) { + FetchMock.mockResolvedValue({ ok: options.ok ?? true, status: options.status ?? 200, json: async () => body, @@ -20,8 +20,8 @@ function respondWith(body: unknown, options: { ok?: boolean; status?: number } = } // The parsed JSON body of the n-th request -function requestBody(call = 0) { - return JSON.parse(fetchMock.mock.calls[call][1].body as string) as { +function RequestBody(call = 0) { + return JSON.parse(FetchMock.mock.calls[call][1].body as string) as { query: string variables: Record } @@ -40,8 +40,8 @@ const sampleGame = { describe('wishlistLogicFrontend', () => { beforeEach(() => { - fetchMock.mockReset() - vi.stubGlobal('fetch', fetchMock) + FetchMock.mockReset() + vi.stubGlobal('fetch', FetchMock) }) afterEach(() => { @@ -50,12 +50,12 @@ describe('wishlistLogicFrontend', () => { describe('request format', () => { it('sends a JSON POST request to the GraphQL server', async () => { - respondWith({ data: { wishlist: [] } }) + RespondWith({ data: { wishlist: [] } }) - await fetchWishlistFrontend(1) + await FetchWishlistFrontend(1) - expect(fetchMock).toHaveBeenCalledOnce() - const [url, options] = fetchMock.mock.calls[0] + expect(FetchMock).toHaveBeenCalledOnce() + const [url, options] = FetchMock.mock.calls[0] expect(typeof url).toBe('string') expect(options.method).toBe('POST') expect(options.headers).toEqual({ 'Content-Type': 'application/json' }) @@ -64,11 +64,11 @@ describe('wishlistLogicFrontend', () => { describe('fetchWishlistFrontend', () => { it('asks for the wishlist of the given user with all game fields', async () => { - respondWith({ data: { wishlist: [] } }) + RespondWith({ data: { wishlist: [] } }) - await fetchWishlistFrontend(9) + await FetchWishlistFrontend(9) - const { query, variables } = requestBody() + const { query, variables } = RequestBody() expect(query).toContain('wishlist(userId: $userId)') expect(query).toContain('$userId: Int!') for (const field of [ @@ -87,40 +87,40 @@ describe('wishlistLogicFrontend', () => { }) it('returns the games from the response', async () => { - respondWith({ data: { wishlist: [sampleGame] } }) + RespondWith({ data: { wishlist: [sampleGame] } }) - await expect(fetchWishlistFrontend(1)).resolves.toEqual([sampleGame]) + await expect(FetchWishlistFrontend(1)).resolves.toEqual([sampleGame]) }) it('returns an empty list for an empty wishlist', async () => { - respondWith({ data: { wishlist: [] } }) + RespondWith({ data: { wishlist: [] } }) - await expect(fetchWishlistFrontend(1)).resolves.toEqual([]) + await expect(FetchWishlistFrontend(1)).resolves.toEqual([]) }) }) describe('addToWishlistFrontend', () => { it('sends the addToWishlist mutation with the user and game id', async () => { - respondWith({ data: { addToWishlist: true } }) + RespondWith({ data: { addToWishlist: true } }) - await addToWishlistFrontend(1, 570) + await AddToWishlistFrontend(1, 570) - const { query, variables } = requestBody() + const { query, variables } = RequestBody() expect(query).toContain('mutation') expect(query).toContain('addToWishlist(userId: $userId, gameId: $gameId)') expect(variables).toEqual({ userId: 1, gameId: 570 }) }) it('resolves with nothing on success', async () => { - respondWith({ data: { addToWishlist: true } }) + RespondWith({ data: { addToWishlist: true } }) - await expect(addToWishlistFrontend(1, 570)).resolves.toBeUndefined() + await expect(AddToWishlistFrontend(1, 570)).resolves.toBeUndefined() }) it('throws the server message when the mutation fails', async () => { - respondWith({ errors: [{ message: 'User or game does not exist' }] }) + RespondWith({ errors: [{ message: 'User or game does not exist' }] }) - await expect(addToWishlistFrontend(999, 570)).rejects.toThrow( + await expect(AddToWishlistFrontend(999, 570)).rejects.toThrow( 'User or game does not exist', ) }) @@ -128,78 +128,78 @@ describe('wishlistLogicFrontend', () => { describe('removeFromWishlistFrontend', () => { it('sends the removeFromWishlist mutation with the user and game id', async () => { - respondWith({ data: { removeFromWishlist: true } }) + RespondWith({ data: { removeFromWishlist: true } }) - await removeFromWishlistFrontend(1, 570) + await RemoveFromWishlistFrontend(1, 570) - const { query, variables } = requestBody() + const { query, variables } = RequestBody() expect(query).toContain('mutation') expect(query).toContain('removeFromWishlist(userId: $userId, gameId: $gameId)') expect(variables).toEqual({ userId: 1, gameId: 570 }) }) it('resolves with nothing on success', async () => { - respondWith({ data: { removeFromWishlist: true } }) + RespondWith({ data: { removeFromWishlist: true } }) - await expect(removeFromWishlistFrontend(1, 570)).resolves.toBeUndefined() + await expect(RemoveFromWishlistFrontend(1, 570)).resolves.toBeUndefined() }) it('throws the server message when the mutation fails', async () => { - respondWith({ errors: [{ message: 'Something went wrong' }] }) + RespondWith({ errors: [{ message: 'Something went wrong' }] }) - await expect(removeFromWishlistFrontend(1, 570)).rejects.toThrow('Something went wrong') + await expect(RemoveFromWishlistFrontend(1, 570)).rejects.toThrow('Something went wrong') }) }) describe('toggleGameWishlist', () => { it('removes the game when it is already wishlisted', async () => { - respondWith({ data: { removeFromWishlist: true } }) + RespondWith({ data: { removeFromWishlist: true } }) - await toggleGameWishlist(1, 570, true) + await ToggleGameWishlist(1, 570, true) - expect(fetchMock).toHaveBeenCalledOnce() - const { query, variables } = requestBody() + expect(FetchMock).toHaveBeenCalledOnce() + const { query, variables } = RequestBody() expect(query).toContain('removeFromWishlist') expect(query).not.toContain('mutation($userId: Int!, $gameId: Int!) { addToWishlist') expect(variables).toEqual({ userId: 1, gameId: 570 }) }) it('adds the game when it is not wishlisted', async () => { - respondWith({ data: { addToWishlist: true } }) + RespondWith({ data: { addToWishlist: true } }) - await toggleGameWishlist(1, 570, false) + await ToggleGameWishlist(1, 570, false) - expect(fetchMock).toHaveBeenCalledOnce() - const { query, variables } = requestBody() + expect(FetchMock).toHaveBeenCalledOnce() + const { query, variables } = RequestBody() expect(query).toContain('addToWishlist') expect(query).not.toContain('removeFromWishlist') expect(variables).toEqual({ userId: 1, gameId: 570 }) }) it('passes errors on to the caller', async () => { - respondWith({ errors: [{ message: 'nope' }] }) + RespondWith({ errors: [{ message: 'nope' }] }) - await expect(toggleGameWishlist(1, 570, false)).rejects.toThrow('nope') + await expect(ToggleGameWishlist(1, 570, false)).rejects.toThrow('nope') }) }) describe('error handling', () => { it('throws when the server answers with an HTTP error', async () => { - respondWith({}, { ok: false, status: 400 }) + RespondWith({}, { ok: false, status: 400 }) - await expect(fetchWishlistFrontend(1)).rejects.toThrow('Request failed: 400') + await expect(FetchWishlistFrontend(1)).rejects.toThrow('Request failed: 400') }) it('throws the first GraphQL error message', async () => { - respondWith({ errors: [{ message: 'first' }, { message: 'second' }] }) + RespondWith({ errors: [{ message: 'first' }, { message: 'second' }] }) - await expect(fetchWishlistFrontend(1)).rejects.toThrow('first') + await expect(FetchWishlistFrontend(1)).rejects.toThrow('first') }) it('passes network failures on to the caller', async () => { - fetchMock.mockRejectedValue(new TypeError('Failed to fetch')) + FetchMock.mockRejectedValue(new TypeError('Failed to fetch')) - await expect(fetchWishlistFrontend(1)).rejects.toThrow('Failed to fetch') + await expect(FetchWishlistFrontend(1)).rejects.toThrow('Failed to fetch') }) }) -}) \ No newline at end of file +}) diff --git a/src/tests/searchpage.test.tsx b/src/tests/searchpage.test.tsx deleted file mode 100644 index 1e47f30..0000000 --- a/src/tests/searchpage.test.tsx +++ /dev/null @@ -1,353 +0,0 @@ -// @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MemoryRouter } from 'react-router-dom' -import type { Game } from '../backend/steam' - -const { getGames, fetchGenres } = vi.hoisted(() => ({ -    getGames: vi.fn<() => Promise>(), -    fetchGenres: vi.fn<() => Promise>(), -})) - -vi.mock('../api/gameClient', () => ({ getGames })) - -vi.mock('../api/searchpageLogicFrontend', async () => { -    const actual = await vi.importActual< -        typeof import('../api/searchpageLogicFrontend') -    >('../api/searchpageLogicFrontend') - -    return { -        ...actual, -        fetchGenres, -    } -}) - -vi.mock('../components/header', () => ({ -    default: () =>
Header
, -})) - -vi.mock('../components/footer', () => ({ -    default: () =>
Footer
, -})) - -vi.mock('../components/pageheading', () => ({ -    default: ({ title }: { title: string }) =>

{title}

, -})) - -vi.mock('../components/passivegamecard', () => ({ -    default: ({ game }: { game: { name: string; price: number } }) => ( -       
-       

{game.name}

-        {game.price.toFixed(2)} -       
-    ), -})) - -import { SearchPage } from '../pages/searchpage' - -function makeGame(appid: number, overrides: Partial = {}): Game { -    return { -        appid, -        name: `Game ${appid}`, -        price: '9.99', -        initialprice: '19.99', -        discount: '50', -        img: `https://example.com/game-${appid}.jpg`, -        genre: 'Action', -        description: `Description for game ${appid}`, -        ...overrides, -    } -} - -const doom = makeGame(1, { name: 'Doom', price: '30', discount: '50', genre: 'Action, Indie' }) -const celeste = makeGame(2, { name: 'Celeste', price: '20', discount: '10', genre: 'Indie, Adventure' }) -const anno = makeGame(3, { name: 'Anno', price: '40', discount: '0', genre: 'Strategy' }) -const namedGames = [doom, celeste, anno] - -function renderSearchPage() { -    return render( -        -            -        , -    ) -} - -function shownTitles(known: Game[] = namedGames): string[] { -    const names = known.map((game) => game.name) -    return screen -        .queryAllByRole('heading', { hidden: true }) -        .map((heading) => heading.textContent ?? '') -        .filter((text) => names.includes(text)) -} - -async function renderWithNamedGames() { -    getGames.mockResolvedValue(namedGames) -    renderSearchPage() -    await screen.findByRole('heading', { name: 'Doom' }) -} - -function openFilters() { -    fireEvent.click(screen.getByRole('button', { name: 'Toggle filters' })) -} - -function search(text: string) { -    const input = screen.getByPlaceholderText('Search here or use filter') -    fireEvent.change(input, { target: { value: text } }) -    fireEvent.submit(input.closest('form') as HTMLFormElement) -} - -function chooseSort(optionName: string) { -    openFilters() -    fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Sort by' })) -    fireEvent.click(screen.getByRole('option', { name: optionName })) -} - -describe('SearchPage', () => { -    beforeEach(() => { -        getGames.mockReset() -        fetchGenres.mockReset() -        fetchGenres.mockResolvedValue(['Action', 'Adventure', 'Indie', 'Strategy']) -        sessionStorage.clear() -    }) - -    afterEach(() => { -        cleanup() -    }) - -    describe('loading and rendering', () => { -        it('shows a loading message while the games are being fetched', () => { -            getGames.mockReturnValue(new Promise(() => {})) - -            renderSearchPage() - -            expect(screen.getByText('Loading games...')).toBeTruthy() -            expect(getGames).toHaveBeenCalledOnce() -        }) - -        it('renders the fetched games and paginates the results', async () => { -            getGames.mockResolvedValue(Array.from({ length: 13 }, (_, index) => makeGame(index + 1))) - -            renderSearchPage() - -            expect(await screen.findByRole('heading', { name: 'Game 1' })).toBeTruthy() -            expect(screen.getByRole('heading', { name: 'Game 12' })).toBeTruthy() -            expect(screen.queryByRole('heading', { name: 'Game 13' })).toBeNull() -            expect(screen.getAllByText('9.99')).toHaveLength(12) - -            fireEvent.click(screen.getAllByRole('button', { name: 'Go to page 2' })[0]) - -            expect(await screen.findByRole('heading', { name: 'Game 13' })).toBeTruthy() -            expect(screen.queryByRole('heading', { name: 'Game 1' })).toBeNull() -        }) - -        it('shows an empty state when no games are returned', async () => { -            getGames.mockResolvedValue([]) - -            renderSearchPage() - -            expect(await screen.findByText('No games found.')).toBeTruthy() -        }) - -        it('shows an error when loading games fails', async () => { -            getGames.mockRejectedValue(new Error('Database unavailable')) - -            renderSearchPage() - -            const alert = await screen.findByRole('alert') -            expect(alert.textContent).toBe('Could not load games: Database unavailable') -            await waitFor(() => expect(screen.queryByText('Loading games...')).toBeNull()) -        }) - -        it('toggles the filter controls', async () => { -            getGames.mockResolvedValue([]) - -            renderSearchPage() - -            const filterToggle = screen.getByRole('button', { name: 'Toggle filters' }) -            expect(filterToggle.getAttribute('aria-pressed')).toBe('false') -            expect(screen.queryByText('Sort by:')).toBeNull() - -            fireEvent.click(filterToggle) - -            expect(filterToggle.getAttribute('aria-pressed')).toBe('true') -            expect(screen.getByRole('combobox', { name: 'Sort by' })).toBeTruthy() -            expect(screen.getByRole('combobox', { name: 'Filter by genre' })).toBeTruthy() -        }) -    }) - -    describe('search', () => { -        it('filters by name on submit, ignoring case', async () => { -            await renderWithNamedGames() - -            search('DOO') - -            expect(shownTitles()).toEqual(['Doom']) -        }) - -        it('does not filter until the search is submitted', async () => { -            await renderWithNamedGames() - -            fireEvent.change(screen.getByPlaceholderText('Search here or use filter'), { -                target: { value: 'doom' }, -            }) - -            expect(shownTitles()).toHaveLength(3) -        }) - -        it('works with the search button', async () => { -            await renderWithNamedGames() - -            fireEvent.change(screen.getByPlaceholderText('Search here or use filter'), { -                target: { value: 'celeste' }, -            }) -            fireEvent.click(screen.getByRole('button', { name: 'search' })) - -            expect(shownTitles()).toEqual(['Celeste']) -        }) - -        it('shows an empty state when nothing matches', async () => { -            await renderWithNamedGames() - -            search('zzz') - -            expect(screen.getByText('No games found.')).toBeTruthy() -            expect(shownTitles()).toHaveLength(0) -        }) - -        it('shows all games again when the search is cleared', async () => { -            await renderWithNamedGames() - -            search('doom') -            search('') - -            expect(shownTitles()).toHaveLength(3) -        }) -    }) - -    describe('sorting', () => { -        it('sorts by price, low to high', async () => { -            await renderWithNamedGames() -            chooseSort('Price: low to high') -            expect(shownTitles()).toEqual(['Celeste', 'Doom', 'Anno']) -        }) - -        it('sorts by price, high to low', async () => { -            await renderWithNamedGames() -            chooseSort('Price: high to low') -            expect(shownTitles()).toEqual(['Anno', 'Doom', 'Celeste']) -        }) - -        it('sorts by discount, high to low', async () => { -            await renderWithNamedGames() -            chooseSort('Discount: high to low') -            expect(shownTitles()).toEqual(['Doom', 'Celeste', 'Anno']) -        }) - -        it('sorts by title, A to Z', async () => { -            await renderWithNamedGames() -            chooseSort('Title: A to Z') -            expect(shownTitles()).toEqual(['Anno', 'Celeste', 'Doom']) -        }) -    }) - -    describe('genre filter', () => { -        async function openGenreList() { -            openFilters() -            fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Filter by genre' })) -            await screen.findByRole('option', { name: 'Indie' }) -        } - -        it('lists the genres returned by the backend', async () => { -            await renderWithNamedGames() -            await openGenreList() - -            const options = screen.getAllByRole('option').map((option) => option.textContent) -            expect(options).toEqual(['Action', 'Adventure', 'Indie', 'Strategy']) -        }) - -        it('keeps games that contain the selected genre', async () => { -            await renderWithNamedGames() -            await openGenreList() - -            fireEvent.click(screen.getByRole('option', { name: 'Indie' })) - -            expect(shownTitles()).toEqual(['Doom', 'Celeste']) -        }) - -        it('requires all selected genres', async () => { -            await renderWithNamedGames() -            await openGenreList() - -            fireEvent.click(screen.getByRole('option', { name: 'Action' })) -            fireEvent.click(screen.getByRole('option', { name: 'Indie' })) - -            expect(shownTitles()).toEqual(['Doom']) -        }) - -        it('combines the genre filter with the search', async () => { -            await renderWithNamedGames() -            await openGenreList() -            fireEvent.click(screen.getByRole('option', { name: 'Indie' })) - -            search('celeste') - -            expect(shownTitles()).toEqual(['Celeste']) -        }) -    }) - -    describe('pagination and search', () => { -        it('goes back to page 1 when the search changes', async () => { -            getGames.mockResolvedValue(Array.from({ length: 15 }, (_, index) => makeGame(index + 1))) -            renderSearchPage() -            await screen.findByRole('heading', { name: 'Game 1' }) - -            fireEvent.click(screen.getAllByRole('button', { name: 'Go to page 2' })[0]) -            await screen.findByRole('heading', { name: 'Game 13' }) - -            search('Game 1') - -            expect(await screen.findByRole('heading', { name: 'Game 1' })).toBeTruthy() -            expect(screen.getByRole('heading', { name: 'Game 15' })).toBeTruthy() -        }) -    }) - -    describe('sessionStorage', () => { -        it('saves the submitted search', async () => { -            await renderWithNamedGames() - -            search('doom') - -            await waitFor(() => -                expect(sessionStorage.getItem('GameStart:querySearchpage')).toBe('"doom"'), -            ) -        }) - -        it('restores a saved search and opens the filter controls for a saved sort', async () => { -            sessionStorage.setItem('GameStart:querySearchpage', JSON.stringify('o')) -            sessionStorage.setItem('GameStart:sortOptionSearchpage', JSON.stringify('price-desc')) - -            await renderWithNamedGames() -            expect(shownTitles()).toEqual(['Anno', 'Doom']) - -            const input = screen.getByPlaceholderText('Search here or use filter') as HTMLInputElement -            expect(input.value).toBe('o') -            expect(screen.getByRole('combobox', { name: 'Sort by' })).toBeTruthy() -        }) - -        it('ignores an invalid saved sort option', async () => { -            sessionStorage.setItem('GameStart:sortOptionSearchpage', JSON.stringify('nonsense')) - -            await renderWithNamedGames() - -            expect(shownTitles()).toEqual(['Doom', 'Celeste', 'Anno']) -        }) - -        it('survives corrupted storage', async () => { -            sessionStorage.setItem('GameStart:filtersSearchpage', '{not json') - -            await renderWithNamedGames() - -            expect(shownTitles()).toHaveLength(3) -        }) -    }) -}) \ No newline at end of file diff --git a/src/tests/wishlistBackend.test.ts b/src/tests/wishlistBackend.test.ts deleted file mode 100644 index 0e26ee4..0000000 --- a/src/tests/wishlistBackend.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -// @vitest-environment node -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { query } = vi.hoisted(() => ({ query: vi.fn() })) - -// Replace the real Postgres pool, so no database (or .env file) is needed -vi.mock('../backend/db_connection.ts', () => ({ pool: { query } })) - -import { addToWishlist, removeFromWishlist } from '../backend/wishlistLogic.ts' - -describe('addToWishlist (backend)', () => { - beforeEach(() => { - query.mockReset() - }) - - it('inserts the user and game and returns ok with the game id', async () => { - query.mockResolvedValue({ rows: [], rowCount: 1 }) - - const result = await addToWishlist(1, 570) - - expect(result).toEqual({ ok: true, gameId: 570 }) - expect(query).toHaveBeenCalledOnce() - const [sql, params] = query.mock.calls[0] - expect(String(sql)).toContain('INSERT INTO wishlist') - expect(String(sql)).toContain('user_id') - expect(String(sql)).toContain('game_id') - expect(params).toEqual([1, 570]) - }) - - it('ignores duplicates instead of failing', async () => { - query.mockResolvedValue({ rows: [], rowCount: 0 }) - - await expect(addToWishlist(1, 570)).resolves.toEqual({ ok: true, gameId: 570 }) - - expect(String(query.mock.calls[0][0])).toContain('ON CONFLICT (user_id, game_id) DO NOTHING') - }) - - it('uses placeholders, never building the values into the SQL', async () => { - query.mockResolvedValue({ rows: [] }) - - await addToWishlist(7, 4242) - - const sql = String(query.mock.calls[0][0]) - expect(sql).toContain('$1') - expect(sql).toContain('$2') - expect(sql).not.toContain('4242') - }) - - it.each([ - ['userId', 0, 570, 'addToWishlist: userId can not be empty'], - ['gameId', 1, 0, 'addToWishlist: gameId can not be empty'], - ])('rejects a missing %s without touching the database', async (_name, userId, gameId, message) => { - await expect(addToWishlist(userId, gameId)).rejects.toThrow(message) - expect(query).not.toHaveBeenCalled() - }) - - it('turns a foreign key violation into a friendly error', async () => { - query.mockRejectedValue(Object.assign(new Error('fk violation'), { code: '23503' })) - - await expect(addToWishlist(999, 570)).rejects.toThrow( - 'addToWishlist: user or game does not exist', - ) - }) - - it('passes other database errors on unchanged', async () => { - query.mockRejectedValue(new Error('connection refused')) - - await expect(addToWishlist(1, 570)).rejects.toThrow('connection refused') - }) - - it('does not hide errors that only look similar (a different error code)', async () => { - query.mockRejectedValue(Object.assign(new Error('permission denied'), { code: '42501' })) - - await expect(addToWishlist(1, 570)).rejects.toThrow('permission denied') - }) -}) - -describe('removeFromWishlist (backend)', () => { - beforeEach(() => { - query.mockReset() - }) - - it('deletes the matching row and returns ok with the game id', async () => { - query.mockResolvedValue({ rows: [], rowCount: 1 }) - - const result = await removeFromWishlist(1, 570) - - expect(result).toEqual({ ok: true, gameId: 570 }) - expect(query).toHaveBeenCalledOnce() - const [sql, params] = query.mock.calls[0] - expect(String(sql)).toContain('DELETE FROM wishlist') - expect(String(sql)).toContain('user_id = $1') - expect(String(sql)).toContain('game_id = $2') - expect(params).toEqual([1, 570]) - }) - - it('only deletes one user/game pair, never the whole table', async () => { - query.mockResolvedValue({ rows: [], rowCount: 1 }) - - await removeFromWishlist(1, 570) - - expect(String(query.mock.calls[0][0])).toContain('WHERE') - }) - - it('succeeds even if the game was not on the wishlist', async () => { - query.mockResolvedValue({ rows: [], rowCount: 0 }) - - await expect(removeFromWishlist(1, 570)).resolves.toEqual({ ok: true, gameId: 570 }) - }) - - it.each([ - ['userId', 0, 570, 'removeFromWishlist: userId can not be empty'], - ['gameId', 1, 0, 'removeFromWishlist: gameId can not be empty'], - ])('rejects a missing %s without touching the database', async (_name, userId, gameId, message) => { - await expect(removeFromWishlist(userId, gameId)).rejects.toThrow(message) - expect(query).not.toHaveBeenCalled() - }) - - it('passes database errors on to the caller', async () => { - query.mockRejectedValue(new Error('connection refused')) - - await expect(removeFromWishlist(1, 570)).rejects.toThrow('connection refused') - }) -}) \ No newline at end of file diff --git a/src/types/game.ts b/src/types/Game.ts similarity index 100% rename from src/types/game.ts rename to src/types/Game.ts diff --git a/tsconfig.app.json b/tsconfig.app.json index 6830b6f..13f7fe5 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,26 +1,26 @@ { - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "es2023", - "lib": ["ES2023", "DOM"], - "module": "esnext", - "types": ["vite/client"], - "allowArbitraryExtensions": true, - "skipLibCheck": true, + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", - /* Linting */ - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"] + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] } diff --git a/tsconfig.json b/tsconfig.json index fffc94d..29e197a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ], - "compilerOptions": { - "types": ["vitest/globals"] - } + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], + "compilerOptions": { + "types": ["vitest/globals"] + } } - diff --git a/tsconfig.node.json b/tsconfig.node.json index 8455dcb..59a4492 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,23 +1,23 @@ { - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "es2023", - "lib": ["ES2023"], - "types": ["node"], - "skipLibCheck": true, + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, - /* Bundler mode */ - "module": "nodenext", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, - /* Linting */ - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["vite.config.ts"] + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index b144c6c..c135210 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,13 +4,13 @@ import { defineConfig } from 'vite' // https://vite.dev/config/ export default defineConfig({ - base: '/', + base: '/', - //base: '/project2/', - plugins: [react()], - test: { - environment: 'jsdom', - globals: true, - setupFiles: './src/tests/setup.ts', - }, + //base: '/project2/', + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: './src/tests/Setup.ts', + }, })