From 8c9ff6cf0fb4d9ea232ff7922a2d84a4ddfbc6ed Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 12:36:15 +0200 Subject: [PATCH 01/76] feat: remove from wishlist at wishlistpage Adding a button so that a user has the option to remove a game from their wishlist. --- src/components/wishlistgamecard.tsx | 48 +++++++++++++++++------------ src/css/wishlistgamecard.css | 28 +++++++++++++++++ 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/components/wishlistgamecard.tsx b/src/components/wishlistgamecard.tsx index bdf6c0f..a333984 100644 --- a/src/components/wishlistgamecard.tsx +++ b/src/components/wishlistgamecard.tsx @@ -1,17 +1,13 @@ -import Card from '@mui/material/Card' -import CardActionArea from '@mui/material/CardActionArea' -import CardContent from '@mui/material/CardContent' -import CardMedia from '@mui/material/CardMedia' -import Chip from '@mui/material/Chip' -import Typography from '@mui/material/Typography' +import { Card, CardActionArea, CardActions, CardContent, CardMedia, Chip, Typography, Button } from '@mui/material' import '../css/wishlistgamecard.css' import type { PassiveGameData } from '../types/game' -type Props = { +type wishlistGamecardProps = { game: PassiveGameData + onRemoveFromWishlist?: (gameId: number) => void } -export default function WishlistGameCard({ game }: Props) { +export default function WishlistGameCard({ game, onRemoveFromWishlist }: wishlistGamecardProps) { const isOnSale = game.salePercent !== undefined && game.salePercent > 0 && game.salePercent <= 100 @@ -28,20 +24,32 @@ export default function WishlistGameCard({ game }: Props) { {game.name} -
- {isOnSale && ( - - )} - - {game.price.toFixed(2)} - -
+ +
+
+ {isOnSale && ( + + )} + + {game.price.toFixed(2)} + +
+ + + +
) } diff --git a/src/css/wishlistgamecard.css b/src/css/wishlistgamecard.css index ebd3bd2..2e3099f 100644 --- a/src/css/wishlistgamecard.css +++ b/src/css/wishlistgamecard.css @@ -1,4 +1,6 @@ .wishlistGameCard { + display: flex; + align-items: stretch; width: 100%; border: 1px solid var(--primary-component-color); border-radius: 0.375rem; @@ -9,6 +11,8 @@ .wishlistGameCardAction.MuiCardActionArea-root { display: flex; + flex: 1 1 auto; + min-width: 0; align-items: stretch; text-align: left; } @@ -67,6 +71,25 @@ white-space: nowrap; } +.wishlistGameCardPriceAndRemove { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: flex-end; + justify-content: center; + gap: 0.5rem; + padding: 0.875rem 1rem; +} + +.wishlistGameCardPriceAndRemove .MuiCardActions-root { + padding: 0; +} + +.removeFromWishlistbuttonWishlistPage.MuiButton-root { + border-color: var(--primary---secondary-background-color); + color: var(--secondary-background-color); +} + @media (max-width: 36rem) { .wishlistGameCardMedia { flex-basis: 6rem; @@ -90,4 +113,9 @@ .wishlistGameCardPriceGroup { gap: 0.375rem; } + + .wishlistGameCardPriceAndRemove { + gap: 0.375rem; + padding: 0.75rem; + } } From 0682b1e33bb2cf0438aa33aff2ec5ade2c2f8526 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:21:19 +0200 Subject: [PATCH 02/76] fix: back navigate and css Moving all mui styling to the wishlistpage.css file. Also adding a back button so a user can navigate back to the last visitied page. --- src/css/wishlistgamecard.css | 4 +-- src/css/wishlistpage.css | 65 ++++++++++++++++++++++++++++++++++++ src/pages/wishlistpage.tsx | 31 ++++++----------- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/src/css/wishlistgamecard.css b/src/css/wishlistgamecard.css index 2e3099f..7b34ac2 100644 --- a/src/css/wishlistgamecard.css +++ b/src/css/wishlistgamecard.css @@ -86,8 +86,8 @@ } .removeFromWishlistbuttonWishlistPage.MuiButton-root { - border-color: var(--primary---secondary-background-color); - color: var(--secondary-background-color); + border-color: var(--primary-accent-color); + color: var(--primary-accent-color); } @media (max-width: 36rem) { diff --git a/src/css/wishlistpage.css b/src/css/wishlistpage.css index ed66f76..6e60597 100644 --- a/src/css/wishlistpage.css +++ b/src/css/wishlistpage.css @@ -3,3 +3,68 @@ flex-direction: column; min-height: 100dvh; } + +.wishlistPageContainer { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + padding: 1rem; +} + +@media (min-width: 600px) { + .wishlistPageContainer { + padding: 2rem; + } +} + +.wishlistPageContainer::before { + content: ''; + position: absolute; + z-index: -1; + inset: 0; + background-image: radial-gradient(ellipse at 50% 50%, hsl(210, 100%, 97%), hsl(0, 0%, 100%)); +} + +#wishlistContent { + width: 100%; + max-width: 900px; + margin-left: auto; + margin-right: auto; +} + +#wishlistTitleAndSorterBox { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: 0; + margin-bottom: 1rem; +} + +@media (min-width: 600px) { + #wishlistTitleAndSorterBox { + margin-top: 1rem; + margin-bottom: 1.5rem; + } +} + +@media (min-width: 900px) { + #wishlistTitleAndSorterBox { + margin-top: 2rem; + } +} + +.wishlistPageTitle.MuiTypography-root { + font-size: clamp(2rem, 10vw, 2.15rem); +} + +.wishlistSortControl.MuiFormControl-root { + min-width: 140px; +} + +@media (min-width: 600px) { + .wishlistSortControl.MuiFormControl-root { + min-width: 200px; + } +} \ No newline at end of file diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 13fbabe..ed5c089 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -3,10 +3,10 @@ import Header from '../components/header' import Footer from '../components/footer' import { WishlistCard } from '../components/WishlistCard' import Stack from '@mui/material/Stack' -import { styled } from '@mui/material/styles' -import { Typography, Box, FormControl, Select, MenuItem, InputLabel } from '@mui/material' +import { Typography, Box, FormControl, Select, MenuItem, InputLabel, Button, styled } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' @@ -47,6 +47,7 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') + const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) @@ -67,27 +68,17 @@ export default function WishlistPage() { return (
- - - - + + + + + + + Wishlist - + Sort by - + {games.slice(0, 4).map((game) => { const image = imagesById[game.id] From adea1df46c83b8afae082e0cbdf85b5d64ea4083 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:54:30 +0200 Subject: [PATCH 09/76] fix: removing unused imports --- src/pages/wishlistpage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 6c3cb3b..61713fe 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -46,7 +46,6 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') - const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) From 32e08985991ca1f854afef1a3b1eeb25a284b265 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:55:02 +0200 Subject: [PATCH 10/76] chore: removing unused imports --- src/pages/wishlistpage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 61713fe..fc40082 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -7,7 +7,6 @@ import { styled } from '@mui/material/styles' import { Box, FormControl, Select, MenuItem, InputLabel } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' -import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' From 1037a5e15b210f8a99092b9950c7afdf88d844ca Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 14:38:48 +0200 Subject: [PATCH 11/76] feat: place for wishlist backend functions --- src/backend/wishlistLogic.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/backend/wishlistLogic.ts diff --git a/src/backend/wishlistLogic.ts b/src/backend/wishlistLogic.ts new file mode 100644 index 0000000..64ee640 --- /dev/null +++ b/src/backend/wishlistLogic.ts @@ -0,0 +1,7 @@ +export async function addToWishlist(gameid: number, userid: number) { + +} + +export async function removeFromWishlist(gameid: number, userid: number) { + +} From 0441942d565783535450d44ffa8d361444f5576c Mon Sep 17 00:00:00 2001 From: carmennt Date: Fri, 9 Oct 2026 11:52:32 +0200 Subject: [PATCH 12/76] fix: removed unused import --- src/pages/wishlistpage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 3895c3b..1a8de32 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -3,7 +3,7 @@ import Header from '../components/header' import Footer from '../components/footer' import { WishlistCard } from '../components/WishlistCard' import Stack from '@mui/material/Stack' -import { Typography, Box, FormControl, Select, MenuItem, InputLabel, styled } from '@mui/material' +import { Box, FormControl, Select, MenuItem, InputLabel, styled } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' From ae8c1ac0c4921d6d4a6a702891ae1e0b68c9c135 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 12:36:15 +0200 Subject: [PATCH 13/76] feat: remove from wishlist at wishlistpage Adding a button so that a user has the option to remove a game from their wishlist. --- src/components/wishlistgamecard.tsx | 48 +++++++++++++++++------------ src/css/wishlistgamecard.css | 28 +++++++++++++++++ 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/components/wishlistgamecard.tsx b/src/components/wishlistgamecard.tsx index bdf6c0f..a333984 100644 --- a/src/components/wishlistgamecard.tsx +++ b/src/components/wishlistgamecard.tsx @@ -1,17 +1,13 @@ -import Card from '@mui/material/Card' -import CardActionArea from '@mui/material/CardActionArea' -import CardContent from '@mui/material/CardContent' -import CardMedia from '@mui/material/CardMedia' -import Chip from '@mui/material/Chip' -import Typography from '@mui/material/Typography' +import { Card, CardActionArea, CardActions, CardContent, CardMedia, Chip, Typography, Button } from '@mui/material' import '../css/wishlistgamecard.css' import type { PassiveGameData } from '../types/game' -type Props = { +type wishlistGamecardProps = { game: PassiveGameData + onRemoveFromWishlist?: (gameId: number) => void } -export default function WishlistGameCard({ game }: Props) { +export default function WishlistGameCard({ game, onRemoveFromWishlist }: wishlistGamecardProps) { const isOnSale = game.salePercent !== undefined && game.salePercent > 0 && game.salePercent <= 100 @@ -28,20 +24,32 @@ export default function WishlistGameCard({ game }: Props) { {game.name} -
- {isOnSale && ( - - )} - - {game.price.toFixed(2)} - -
+ +
+
+ {isOnSale && ( + + )} + + {game.price.toFixed(2)} + +
+ + + +
) } diff --git a/src/css/wishlistgamecard.css b/src/css/wishlistgamecard.css index cfa80b8..5099bf0 100644 --- a/src/css/wishlistgamecard.css +++ b/src/css/wishlistgamecard.css @@ -1,4 +1,6 @@ .wishlistGameCard { + display: flex; + align-items: stretch; width: 100%; border: 1px solid var(--primary-component-color); border-radius: 0.375rem; @@ -9,6 +11,8 @@ .wishlistGameCardAction.MuiCardActionArea-root { display: flex; + flex: 1 1 auto; + min-width: 0; align-items: stretch; text-align: left; } @@ -67,6 +71,25 @@ white-space: nowrap; } +.wishlistGameCardPriceAndRemove { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: flex-end; + justify-content: center; + gap: 0.5rem; + padding: 0.875rem 1rem; +} + +.wishlistGameCardPriceAndRemove .MuiCardActions-root { + padding: 0; +} + +.removeFromWishlistbuttonWishlistPage.MuiButton-root { + border-color: var(--primary---secondary-background-color); + color: var(--secondary-background-color); +} + @media (max-width: 36rem) { .wishlistGameCardMedia { flex-basis: 6rem; @@ -90,4 +113,9 @@ .wishlistGameCardPriceGroup { gap: 0.375rem; } + + .wishlistGameCardPriceAndRemove { + gap: 0.375rem; + padding: 0.75rem; + } } From 94872f9041845619bcca961af74461528d8febc2 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:21:19 +0200 Subject: [PATCH 14/76] fix: back navigate and css Moving all mui styling to the wishlistpage.css file. Also adding a back button so a user can navigate back to the last visitied page. --- src/css/wishlistgamecard.css | 4 ++-- src/css/wishlistpage.css | 17 +++++++++++++++++ src/pages/wishlistpage.tsx | 6 ++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/css/wishlistgamecard.css b/src/css/wishlistgamecard.css index 5099bf0..be001b6 100644 --- a/src/css/wishlistgamecard.css +++ b/src/css/wishlistgamecard.css @@ -86,8 +86,8 @@ } .removeFromWishlistbuttonWishlistPage.MuiButton-root { - border-color: var(--primary---secondary-background-color); - color: var(--secondary-background-color); + border-color: var(--primary-accent-color); + color: var(--primary-accent-color); } @media (max-width: 36rem) { diff --git a/src/css/wishlistpage.css b/src/css/wishlistpage.css index a5ae935..b516d8d 100644 --- a/src/css/wishlistpage.css +++ b/src/css/wishlistpage.css @@ -16,3 +16,20 @@ width: min(calc(100% - 2rem), 32rem); } } + +#wishlistContent { + width: 100%; + max-width: 900px; + margin-left: auto; + margin-right: auto; +} + +.wishlistSortControl.MuiFormControl-root { + min-width: 140px; +} + +@media (min-width: 600px) { + .wishlistSortControl.MuiFormControl-root { + min-width: 200px; + } +} \ No newline at end of file diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index f75312c..6c3cb3b 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -7,6 +7,7 @@ import { styled } from '@mui/material/styles' import { Box, FormControl, Select, MenuItem, InputLabel } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' @@ -45,6 +46,7 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') + const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) @@ -68,7 +70,7 @@ export default function WishlistPage() { + Sort by - + {games.slice(0, 4).map((game) => { const image = imagesById[game.id] From ed06d02805d53e2c2d86992ad88d158d2c0a093f Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:54:30 +0200 Subject: [PATCH 32/76] fix: removing unused imports --- src/pages/wishlistpage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 6c3cb3b..61713fe 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -46,7 +46,6 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') - const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) From 858bd9b1601e2b26299bb6894709660e77482514 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:55:02 +0200 Subject: [PATCH 33/76] chore: removing unused imports --- src/pages/wishlistpage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 61713fe..fc40082 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -7,7 +7,6 @@ import { styled } from '@mui/material/styles' import { Box, FormControl, Select, MenuItem, InputLabel } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' -import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' From 502fde4d576daa20ad333a74a17faa568b2138a5 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 14:38:48 +0200 Subject: [PATCH 34/76] feat: place for wishlist backend functions --- src/backend/wishlistLogic.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/backend/wishlistLogic.ts diff --git a/src/backend/wishlistLogic.ts b/src/backend/wishlistLogic.ts new file mode 100644 index 0000000..64ee640 --- /dev/null +++ b/src/backend/wishlistLogic.ts @@ -0,0 +1,7 @@ +export async function addToWishlist(gameid: number, userid: number) { + +} + +export async function removeFromWishlist(gameid: number, userid: number) { + +} From 74bb50bc609c9e9dd92111e81bd413699d291b10 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:21:19 +0200 Subject: [PATCH 35/76] fix: back navigate and css Moving all mui styling to the wishlistpage.css file. Also adding a back button so a user can navigate back to the last visitied page. --- src/pages/wishlistpage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index fc40082..6c3cb3b 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -7,6 +7,7 @@ import { styled } from '@mui/material/styles' import { Box, FormControl, Select, MenuItem, InputLabel } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' @@ -45,6 +46,7 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') + const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) From c16910ba7b7a69bcc69c89e5bde1d28e68c65f3c Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:54:30 +0200 Subject: [PATCH 36/76] fix: removing unused imports --- src/pages/wishlistpage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 6c3cb3b..61713fe 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -46,7 +46,6 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') - const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) From 6a4f27d568cae28a016809a3a582e7b049b1811e Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:55:02 +0200 Subject: [PATCH 37/76] chore: removing unused imports --- src/pages/wishlistpage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 61713fe..fc40082 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -7,7 +7,6 @@ import { styled } from '@mui/material/styles' import { Box, FormControl, Select, MenuItem, InputLabel } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' -import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' From 0f5e496d0806eae3f038a7c62df9d3a18ed5087a Mon Sep 17 00:00:00 2001 From: carmennt Date: Fri, 9 Oct 2026 11:52:32 +0200 Subject: [PATCH 38/76] fix: removed unused import --- src/pages/wishlistpage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index fc40082..1a8de32 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -3,8 +3,7 @@ import Header from '../components/header' import Footer from '../components/footer' import { WishlistCard } from '../components/WishlistCard' import Stack from '@mui/material/Stack' -import { styled } from '@mui/material/styles' -import { Box, FormControl, Select, MenuItem, InputLabel } from '@mui/material' +import { Box, FormControl, Select, MenuItem, InputLabel, styled } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' From 202e32ba141979640a45892f1c9494f409c34904 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:21:19 +0200 Subject: [PATCH 39/76] fix: back navigate and css Moving all mui styling to the wishlistpage.css file. Also adding a back button so a user can navigate back to the last visitied page. --- src/pages/wishlistpage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 1a8de32..8a6cc22 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -6,6 +6,7 @@ import Stack from '@mui/material/Stack' import { Box, FormControl, Select, MenuItem, InputLabel, styled } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' @@ -44,6 +45,7 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') + const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) From 6fd7523177786878e9833a08ce838c4fc43ed4a5 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:54:30 +0200 Subject: [PATCH 40/76] fix: removing unused imports --- src/pages/wishlistpage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 8a6cc22..2c9b8df 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -45,7 +45,6 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') - const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) From 567b90e382ab51cf8ae50334296185beb9283033 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:55:02 +0200 Subject: [PATCH 41/76] chore: removing unused imports --- src/pages/wishlistpage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 2c9b8df..1a8de32 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -6,7 +6,6 @@ import Stack from '@mui/material/Stack' import { Box, FormControl, Select, MenuItem, InputLabel, styled } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' -import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' From b7ebd915adcd5069f0fbbb9192b1ae0b2003d5e3 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:21:19 +0200 Subject: [PATCH 42/76] fix: back navigate and css Moving all mui styling to the wishlistpage.css file. Also adding a back button so a user can navigate back to the last visitied page. --- src/pages/wishlistpage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 1a8de32..8a6cc22 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -6,6 +6,7 @@ import Stack from '@mui/material/Stack' import { Box, FormControl, Select, MenuItem, InputLabel, styled } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' @@ -44,6 +45,7 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') + const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) From c37c8ab7982ff0675703b7ba30104803990c61f4 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:54:30 +0200 Subject: [PATCH 43/76] fix: removing unused imports --- src/pages/wishlistpage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 8a6cc22..2c9b8df 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -45,7 +45,6 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') - const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) From 3bf50fda67deb8515b55dee73c8c52e1f2815833 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:55:02 +0200 Subject: [PATCH 44/76] chore: removing unused imports --- src/pages/wishlistpage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 2c9b8df..1a8de32 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -6,7 +6,6 @@ import Stack from '@mui/material/Stack' import { Box, FormControl, Select, MenuItem, InputLabel, styled } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' -import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' From d1a5f876d86ad31d8a557b018332de1245a7a4c9 Mon Sep 17 00:00:00 2001 From: carmennt Date: Mon, 12 Oct 2026 18:02:10 +0200 Subject: [PATCH 45/76] feat: working on wishlist functionality Removing the mocked games from the wishlist to make space for the real data. Updating the sorting function to work with the game type. Putting the mapping fuction inside the wishlistgamecard. The page will be empty for now, since there are no wishlisted games in the DB yet. --- src/backend/wishlistLogic.ts | 2 + src/pages/wishlistpage.tsx | 79 +++++++++++++++++------------------- 2 files changed, 40 insertions(+), 41 deletions(-) diff --git a/src/backend/wishlistLogic.ts b/src/backend/wishlistLogic.ts index 64ee640..b23831d 100644 --- a/src/backend/wishlistLogic.ts +++ b/src/backend/wishlistLogic.ts @@ -1,3 +1,5 @@ +import { pool } from './db_connection.ts' + export async function addToWishlist(gameid: number, userid: number) { } diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 1a8de32..3a8825f 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -5,23 +5,13 @@ import { WishlistCard } from '../components/WishlistCard' import Stack from '@mui/material/Stack' import { Box, FormControl, Select, MenuItem, InputLabel, styled } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' -import { useState } from 'react' +import { useState, useEffect, useMemo } from 'react' +import type { Game } from '../backend/steam' +import fetchGamesFromDB from '../api/gameClient' -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 WishlistGameCard from '../components/wishlistgamecard' import PageHeading from '../components/pageheading' -const imagesById: Record = { - 1: bigWalk, - 2: barotrauma, - 3: silksong, - 4: slayTheSpire, -} - const WishlistContainer = styled(Stack)(({ theme }) => ({ position: 'relative', flexGrow: 1, @@ -44,22 +34,34 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') + const [games, setGames] = useState([]) + const [error, setError] = useState(null) + + // TODO: Change to fetch the wishlisted games not all games + useEffect(() => { + fetchGamesFromDB(3, 0) + .then(setGames) + .catch((err) => setError(err.message)) + }, []) const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) } // Takes the list of wishlisted games and sorts them, sorts when the page is loaded, so "Discount: High to low" will be the default - // const sortedGames = useMemo(() => { - // const list = [...games] - // switch (sortBy) { - // case 'discount-desc': return list.sort((a, b) => b.discount - a.discount) - // case 'discount-asc': return list.sort((a, b) => a.discount - b.discount) - // case 'price-asc': return list.sort((a, b) => a.price - b.price) - // case 'price-desc': return list.sort((a, b) => b.price - a.price) - // case 'title-asc': return list.sort((a, b) => a.title.localeCompare(b.title)) - // } - // }, [games, sortBy]) + const sortedGames = useMemo(() => { + const list = [...games] + const num = (v: unknown) => Number(v) || 0 + + switch (sortBy) { + case 'discount-desc': return list.sort((a, b) => num(b.discount) - num(a.discount)) + 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)) + default: return list + } + }, [games, sortBy]) return (
@@ -87,24 +89,19 @@ export default function WishlistPage() { - {games.slice(0, 4).map((game) => { - const image = imagesById[game.id] - if (!image) return null - - return ( -
- -
- ) - })} + {error &&

Error: {error}

} + {sortedGames.map((game) => ( + + ))}
From 9feb93856b083c14376b871f93aa2c0ed65b6e6d Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 13 Oct 2026 10:21:08 +0200 Subject: [PATCH 46/76] fix: comments in wishlistpage.tsx --- src/pages/wishlistpage.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 3a8825f..c652cf5 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -33,11 +33,11 @@ const WishlistContainer = styled(Stack)(({ theme }) => ({ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' | 'title-asc' export default function WishlistPage() { - const [sortBy, setSortBy] = useState('discount-desc') + const [sortBy, setSortBy] = useState('discount-desc') // "Discount: High to low" will be the default const [games, setGames] = useState([]) const [error, setError] = useState(null) - // TODO: Change to fetch the wishlisted games not all games + // TODO: Change to fetch the wishlisted games not all games, waiting for implementation of users useEffect(() => { fetchGamesFromDB(3, 0) .then(setGames) @@ -48,7 +48,7 @@ export default function WishlistPage() { setSortBy(e.target.value as SortOption) } - // Takes the list of wishlisted games and sorts them, sorts when the page is loaded, so "Discount: High to low" will be the default + // Takes the list of wishlisted games and sorts them, sorts when the page is loaded const sortedGames = useMemo(() => { const list = [...games] const num = (v: unknown) => Number(v) || 0 @@ -59,7 +59,6 @@ export default function WishlistPage() { 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)) - default: return list } }, [games, sortBy]) From 88ade277cd91cde6c62d9f86c230341e75343ba8 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 13 Oct 2026 12:20:35 +0200 Subject: [PATCH 47/76] feat: backend logic for wishlist functionality Adding backend logic for the wishlist functionality. There are functions in frontend that call the backend, that will call the DB. --- src/api/wishlistLogicFrontend.ts | 61 ++++++++++++++++++++++++++++++++ src/backend/server.ts | 49 +++++++++++++++++++------ src/backend/wishlistLogic.ts | 30 +++++++++++++--- 3 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 src/api/wishlistLogicFrontend.ts diff --git a/src/api/wishlistLogicFrontend.ts b/src/api/wishlistLogicFrontend.ts new file mode 100644 index 0000000..fb2e488 --- /dev/null +++ b/src/api/wishlistLogicFrontend.ts @@ -0,0 +1,61 @@ +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): 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) + } + +// toggle calls like this: +// const wishlistedIds = new Set(wishlist.map((g) => g.appid)); + +// await toggleGameWishlist(userId, game.appid, wishlistedIds.has(game.appid)); +// setWishlist(await fetchWishlist(userId)); \ No newline at end of file diff --git a/src/backend/server.ts b/src/backend/server.ts index 39d5db8..f2d5929 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -3,9 +3,21 @@ import { startStandaloneServer } from '@apollo/server/standalone' import { type Game } from './steam.ts' import { pool } from './db_connection.ts' import { signin, signup } from './userlogic_backend.ts' +import { addToWishlist, removeFromWishlist } from './wishlistLogic.ts' // Documentation on apollo: https://www.apollographql.com/docs/deploy-preview/5dc6222f824f9e089e2a5735/apollo-server/api/apollo-server +// Shared column list so `games` and `wishlist` return the same Game shape +const GAME_COLUMNS = ` + g.game_id AS appid, + g.game_name AS name, + g.current_price::text AS price, + g.initial_price::text AS initialprice, + g.discount_percent::text AS discount, + g.image_url AS img, + g.genre, + g.description` + //This is a schema for the server. It tells the shape of the data and which queries exist. const typeDefs = `#graphql type Game { @@ -27,9 +39,12 @@ const typeDefs = `#graphql type Query { games(limit: Int = 500, offset: Int = 0): [Game!]! + wishlist(userId: Int!): [Game!]! } - + type Mutation { + addToWishlist(userId: Int!, gameId: Int!): Boolean! + removeFromWishlist(userId: Int!, gameId: Int!): Boolean! signup(username: String!, email: String!, password: String!): User! signin(email: String!, password: String!): User! } @@ -42,23 +57,35 @@ const resolvers = { const safeLimit = Math.min(Math.max(limit, 0), 500) const safeOffset = Math.max(offset, 0) const { rows } = await pool.query( - `SELECT game_id AS appid, - game_name AS name, - current_price::text AS price, - initial_price::text AS initialprice, - discount_percent::text AS discount, - image_url AS img, - genre, - description - FROM games - ORDER BY game_id + `SELECT ${GAME_COLUMNS} + FROM games g + ORDER BY g.game_id LIMIT $1 OFFSET $2`, [safeLimit, safeOffset] ) return rows }, + wishlist: async (_: unknown, { userId }: { userId: number }): Promise => { + const { rows } = await pool.query( + `SELECT ${GAME_COLUMNS} + FROM wishlist w + JOIN games g ON g.game_id = w.game_id + WHERE w.user_id = $1 + ORDER BY g.game_name`, + [userId] + ) + return rows + }, }, Mutation: { + 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) + return true + }, 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 }) => diff --git a/src/backend/wishlistLogic.ts b/src/backend/wishlistLogic.ts index b23831d..3542760 100644 --- a/src/backend/wishlistLogic.ts +++ b/src/backend/wishlistLogic.ts @@ -1,9 +1,31 @@ import { pool } from './db_connection.ts' -export async function addToWishlist(gameid: number, userid: number) { +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(gameid: number, userid: number) { - -} +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 From a5577ba1bb63270d61fd36ca643e961104bc6298 Mon Sep 17 00:00:00 2001 From: carmennt Date: Thu, 15 Oct 2026 23:27:55 +0200 Subject: [PATCH 48/76] feat: fetching games for wishlist Needs to be tested with updated backend, and user_id fetch from logged in user. --- src/components/WishlistCard.tsx | 5 -- src/css/wishlistpage.css | 21 +++-- src/pages/wishlistpage.tsx | 141 ++++++++++++++++++++------------ 3 files changed, 97 insertions(+), 70 deletions(-) diff --git a/src/components/WishlistCard.tsx b/src/components/WishlistCard.tsx index b51e441..a3c6480 100644 --- a/src/components/WishlistCard.tsx +++ b/src/components/WishlistCard.tsx @@ -8,14 +8,9 @@ export const WishlistCard = styled(MuiCard)(({ theme }) => ({ boxSizing: 'border-box', padding: theme.spacing(2), gap: theme.spacing(2), - // margin: 'auto', boxShadow: '0px 5px 15px 0px var(--box-shadow-color), 0px 15px 35px -5px var(--box-shadow-color)', [theme.breakpoints.up('sm')]: { padding: theme.spacing(4), }, - ...theme.applyStyles('dark', { - boxShadow: - '0px 5px 15px 0px var(--box-shadow-color), 0px 15px 35px -5px var(--box-shadow-color)', - }), })) diff --git a/src/css/wishlistpage.css b/src/css/wishlistpage.css index b516d8d..3fac7a0 100644 --- a/src/css/wishlistpage.css +++ b/src/css/wishlistpage.css @@ -4,26 +4,25 @@ min-height: 100dvh; } -.wishlistContent { - box-sizing: border-box; +.wishlistPagination { + margin: 1rem 0; +} + +.wishlistPagination .MuiPagination-ul { + justify-content: center; +} + +.page-shell-wishlist .wishlistCard { width: min(calc(100% - 3rem), 68rem); margin: 0 auto; - padding-left: 3.25rem; } @media (max-width: 43.75rem) { - .wishlistContent { + .page-shell-wishlist .wishlistCard { width: min(calc(100% - 2rem), 32rem); } } -#wishlistContent { - width: 100%; - max-width: 900px; - margin-left: auto; - margin-right: auto; -} - .wishlistSortControl.MuiFormControl-root { min-width: 140px; } diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index c652cf5..3ce88d6 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -1,56 +1,50 @@ import '../css/wishlistpage.css' import Header from '../components/header' import Footer from '../components/footer' -import { WishlistCard } from '../components/WishlistCard' -import Stack from '@mui/material/Stack' -import { Box, FormControl, Select, MenuItem, InputLabel, styled } from '@mui/material' +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 fetchGamesFromDB from '../api/gameClient' -import WishlistGameCard from '../components/wishlistgamecard' import PageHeading from '../components/pageheading' - -const WishlistContainer = styled(Stack)(({ theme }) => ({ - position: 'relative', - flexGrow: 1, - paddingBottom: theme.spacing(4), - '&::before': { - content: '""', - position: 'absolute', - zIndex: -1, - inset: 0, - backgroundImage: - 'radial-gradient(ellipse at 50% 50%, hsl(210, 100%, 97%), hsl(0, 0%, 100%))', - ...theme.applyStyles('dark', { - backgroundImage: - 'radial-gradient(at 50% 50%, hsla(210, 100%, 16%, 0.5), hsl(220, 30%, 5%))', - }), - }, -})) +import { fetchWishlistFrontend } from '../api/wishlistLogicFrontend' +import { WishlistCard } from '../components/WishlistCard' type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' | 'title-asc' export default function WishlistPage() { - const [sortBy, setSortBy] = useState('discount-desc') // "Discount: High to low" will be the default - const [games, setGames] = useState([]) + const [sortBy, setSortBy] = useState('discount-desc') + const [wishlistGames, setWishlistGames] = useState([]) const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [page, setPage] = useState(1) - // TODO: Change to fetch the wishlisted games not all games, waiting for implementation of users + // Loades the games useEffect(() => { - fetchGamesFromDB(3, 0) - .then(setGames) - .catch((err) => setError(err.message)) - }, []) + let cancelled = false + // CHANGE USER_ID + fetchWishlistFrontend(9) + .then((loadedGames) => { + if (!cancelled) setWishlistGames(loadedGames) + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Could not load games') + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) - const handleSortChange = (e: SelectChangeEvent) => { - setSortBy(e.target.value as SortOption) - } + return () => { + cancelled = true + } + }, []) // Takes the list of wishlisted games and sorts them, sorts when the page is loaded const sortedGames = useMemo(() => { - const list = [...games] + const list = [...wishlistGames] const num = (v: unknown) => Number(v) || 0 switch (sortBy) { @@ -60,7 +54,20 @@ export default function WishlistPage() { 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)) } - }, [games, sortBy]) + }, [wishlistGames, sortBy]) + + // Decides games per page and makes a constant for the pagination + const gamesPerPage = 12 + const pageCount = Math.max(1, Math.ceil(sortedGames.length / gamesPerPage)) + + const visibleGames = useMemo(() => { + const start = (page - 1) * gamesPerPage + return sortedGames.slice(start, start + gamesPerPage) + }, [sortedGames, page]) + + const handleSortChange = (e: SelectChangeEvent) => { + setSortBy(e.target.value as SortOption) + } return (
@@ -85,25 +92,51 @@ export default function WishlistPage() { } /> - - - - {error &&

Error: {error}

} - {sortedGames.map((game) => ( - - ))} -
-
-
+ { + setPage(value) + }} + /> + + {error &&

Could not load games: {error}

} + {isLoading ? ( +

Loading games...

+ ) : sortedGames.length === 0 ? ( +

No games found.

+ ) : ( +
+ {visibleGames.map((game) => { + const price = Number(game.price) + const salePercent = Number(game.discount) + + return ( + + ) + })} +
+ )} +
+ { + setPage(value) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }} + />
) From c88ad5a4c38e110f9f75eb4973ce8b85a8881f3d Mon Sep 17 00:00:00 2001 From: carmennt Date: Thu, 15 Oct 2026 23:37:53 +0200 Subject: [PATCH 49/76] test: Tests for backend and fronted for wishlist --- src/tests/wishlistBackend.test.ts | 124 ++++++++++++++++++ src/tests/wishlistpage.test.tsx | 205 ++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 src/tests/wishlistBackend.test.ts create mode 100644 src/tests/wishlistpage.test.tsx diff --git a/src/tests/wishlistBackend.test.ts b/src/tests/wishlistBackend.test.ts new file mode 100644 index 0000000..0e26ee4 --- /dev/null +++ b/src/tests/wishlistBackend.test.ts @@ -0,0 +1,124 @@ +// @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/tests/wishlistpage.test.tsx b/src/tests/wishlistpage.test.tsx new file mode 100644 index 0000000..3e24190 --- /dev/null +++ b/src/tests/wishlistpage.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + addToWishlistFrontend, + fetchWishlistFrontend, + removeFromWishlistFrontend, + toggleGameWishlist, +} from '../api/wishlistLogicFrontend' + +const fetchMock = vi.fn() + +// Makes fetch answer like a GraphQL server +function respondWith(body: unknown, options: { ok?: boolean; status?: number } = {}) { + fetchMock.mockResolvedValue({ + ok: options.ok ?? true, + status: options.status ?? 200, + json: async () => body, + }) +} + +// 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 { + query: string + variables: Record + } +} + +const sampleGame = { + appid: 570, + name: 'Dota 2', + price: '0', + initialprice: '0', + discount: '0', + img: 'https://example.com/dota.jpg', + genre: 'Action', + description: 'A game', +} + +describe('wishlistLogicFrontend', () => { + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('request format', () => { + it('sends a JSON POST request to the GraphQL server', async () => { + respondWith({ data: { wishlist: [] } }) + + await fetchWishlistFrontend(1) + + 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' }) + }) + }) + + describe('fetchWishlistFrontend', () => { + it('asks for the wishlist of the given user with all game fields', async () => { + respondWith({ data: { wishlist: [] } }) + + await fetchWishlistFrontend(9) + + const { query, variables } = requestBody() + expect(query).toContain('wishlist(userId: $userId)') + expect(query).toContain('$userId: Int!') + for (const field of [ + 'appid', + 'name', + 'price', + 'initialprice', + 'discount', + 'img', + 'genre', + 'description', + ]) { + expect(query).toContain(field) + } + expect(variables).toEqual({ userId: 9 }) + }) + + it('returns the games from the response', async () => { + respondWith({ data: { wishlist: [sampleGame] } }) + + await expect(fetchWishlistFrontend(1)).resolves.toEqual([sampleGame]) + }) + + it('returns an empty list for an empty wishlist', async () => { + respondWith({ data: { wishlist: [] } }) + + await expect(fetchWishlistFrontend(1)).resolves.toEqual([]) + }) + }) + + describe('addToWishlistFrontend', () => { + it('sends the addToWishlist mutation with the user and game id', async () => { + respondWith({ data: { addToWishlist: true } }) + + await addToWishlistFrontend(1, 570) + + 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 } }) + + 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' }] }) + + await expect(addToWishlistFrontend(999, 570)).rejects.toThrow( + 'User or game does not exist', + ) + }) + }) + + describe('removeFromWishlistFrontend', () => { + it('sends the removeFromWishlist mutation with the user and game id', async () => { + respondWith({ data: { removeFromWishlist: true } }) + + await removeFromWishlistFrontend(1, 570) + + 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 } }) + + await expect(removeFromWishlistFrontend(1, 570)).resolves.toBeUndefined() + }) + + it('throws the server message when the mutation fails', async () => { + respondWith({ errors: [{ message: '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 } }) + + await toggleGameWishlist(1, 570, true) + + 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 } }) + + await toggleGameWishlist(1, 570, false) + + 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' }] }) + + 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 }) + + await expect(fetchWishlistFrontend(1)).rejects.toThrow('Request failed: 400') + }) + + it('throws the first GraphQL error message', async () => { + respondWith({ errors: [{ message: 'first' }, { message: 'second' }] }) + + await expect(fetchWishlistFrontend(1)).rejects.toThrow('first') + }) + + it('passes network failures on to the caller', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')) + + await expect(fetchWishlistFrontend(1)).rejects.toThrow('Failed to fetch') + }) + }) +}) \ No newline at end of file From 7c9f8ff2b6024fa39242e265c2626ab8b230ec7c Mon Sep 17 00:00:00 2001 From: carmennt Date: Fri, 16 Oct 2026 00:27:49 +0200 Subject: [PATCH 50/76] feat: wishlist button on active game card --- src/components/activegamecard.tsx | 82 +++++++++++++++++++++++++---- src/components/wishlistgamecard.tsx | 29 +++++++++- 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/components/activegamecard.tsx b/src/components/activegamecard.tsx index 6a89c68..62befb4 100644 --- a/src/components/activegamecard.tsx +++ b/src/components/activegamecard.tsx @@ -1,22 +1,80 @@ -import Box from '@mui/material/Box' -import Button from '@mui/material/Button' -import Card from '@mui/material/Card' -import CardContent from '@mui/material/CardContent' -import CardMedia from '@mui/material/CardMedia' -import Chip from '@mui/material/Chip' -import Divider from '@mui/material/Divider' -import Typography from '@mui/material/Typography' +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 { useState, useEffect } from 'react' +import { removeFromWishlistFrontend, + addToWishlistFrontend, + fetchWishlistFrontend } + from '../api/wishlistLogicFrontend' type ActiveGameCardProps = { game: GameData + onRemoveFromWishlist?: (gameId: number) => void onAddToWishlist?: (gameId: number) => void } -export default function ActiveGameCard({ game, onAddToWishlist }: ActiveGameCardProps) { +export default function ActiveGameCard({ game, onAddToWishlist, onRemoveFromWishlist }: ActiveGameCardProps) { + const [isWishlisted, setIsWishlisted] = useState(false) + const [isChecking, setIsChecking] = useState(true) + 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) + .then((wishlist) => { + if (!cancelled) setIsWishlisted(wishlist.some((wishlisted) => wishlisted.appid === game.id)) + }) + .catch(() => { + if (!cancelled) setError('Could not check your wishlist') + }) + .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() + 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) + setIsWishlisted(false) + onRemoveFromWishlist?.(game.id) + } else { + await addToWishlistFrontend(user.id, game.id) + setIsWishlisted(true) + onAddToWishlist?.(game.id) + } + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Could not update wishlist') + } finally { + setIsBusy(false) + } + } return ( @@ -30,10 +88,12 @@ export default function ActiveGameCard({ game, onAddToWishlist }: ActiveGameCard + {error &&

{error}

} diff --git a/src/components/wishlistgamecard.tsx b/src/components/wishlistgamecard.tsx index a333984..729eb8c 100644 --- a/src/components/wishlistgamecard.tsx +++ b/src/components/wishlistgamecard.tsx @@ -1,6 +1,9 @@ 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 { useState } from 'react' type wishlistGamecardProps = { game: PassiveGameData @@ -8,9 +11,31 @@ 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() + if (!user) { + setError('You need to sign in to change your wishlist') + return + } + + setIsRemoving(true) + setError(null) + try { + await removeFromWishlistFrontend(user.id, game.id) + onRemoveFromWishlist?.(game.id) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Could not remove game') + } finally { + setIsRemoving(false) + } + } + return ( @@ -44,11 +69,13 @@ export default function WishlistGameCard({ game, onRemoveFromWishlist }: wishlis + {error &&

{error}

}
) From 5e81791d60e16d5beea58e1710f7d47ba9647a1a Mon Sep 17 00:00:00 2001 From: catjoha04-byte Date: Fri, 16 Oct 2026 08:53:27 +0200 Subject: [PATCH 51/76] test: add tests for mainpage --- src/tests/mainpage.test.tsx | 261 ++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 src/tests/mainpage.test.tsx diff --git a/src/tests/mainpage.test.tsx b/src/tests/mainpage.test.tsx new file mode 100644 index 0000000..0238b08 --- /dev/null +++ b/src/tests/mainpage.test.tsx @@ -0,0 +1,261 @@ +// @vitest-environment jsdom +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' + +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('@mui/material/Autocomplete', () => ({ + default: ({ + disabled, + onChange, + options, + value, + }: { + disabled: boolean + onChange: (_event: unknown, value: string | null) => void + options: string[] + value: string | null + }) => ( + + ), +})) + +import MainPage from '../pages/mainpage' + +function makeGame(appid: number, genre: string, discount = '0', price = '9.99'): Game { + return { + appid, + name: `Game ${appid}`, + price, + initialprice: '19.99', + discount, + img: `https://example.com/game-${appid}.jpg`, + genre, + description: `Description for game ${appid}`, + } +} + +function renderMainPage() { + return render( + + + , + ) +} + +describe('MainPage', () => { + beforeEach(() => { + getGames.mockReset() + useMediaQuery.mockReset() + useMediaQuery.mockReturnValue(false) + }) + + afterEach(() => { + cleanup() + }) + + it('shows loading states while games are being fetched', () => { + getGames.mockReturnValue(new Promise(() => {})) + + renderMainPage() + + expect(screen.getAllByText('Loading games...')).toHaveLength(3) + expect(screen.getByText('Loading reviews...')).toBeTruthy() + expect(getGames).toHaveBeenCalledOnce() + expect( + screen + .getAllByRole('button', { name: 'Previous' }) + .every((button) => button.hasAttribute('disabled')), + ).toBe(true) + }) + + 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'), + ]) + + renderMainPage() + + expect(await screen.findByRole('heading', { name: 'Top 3 Games' })).toBeTruthy() + expect(screen.getByRole('heading', { name: 'Recent reviews' })).toBeTruthy() + expect(screen.getByRole('heading', { name: 'Top 10 Games by Genre' })).toBeTruthy() + expect(screen.getByRole('heading', { name: 'Discounts by Genre' })).toBeTruthy() + expect(screen.getAllByText('Game 1').length).toBeGreaterThan(0) + expect(screen.getAllByText('Game 3').length).toBeGreaterThan(0) + expect(screen.getByText('Review by Reviewer1')).toBeTruthy() + expect(screen.getByText('Review by Reviewer2')).toBeTruthy() + expect(screen.getAllByRole('link', { name: 'See this game' })).toHaveLength(2) + + const genres = screen.getAllByLabelText('Choose a genre') as HTMLSelectElement[] + expect(Array.from(genres[0].options).map((option) => option.value)).toEqual([ + 'Action', + 'Adventure', + ]) + + const genreRows = document.querySelectorAll('.genre-games-row') + expect(within(genreRows[0] as HTMLElement).getByText('Game 1')).toBeTruthy() + expect(within(genreRows[0] as HTMLElement).queryByText('Game 4')).toBeNull() + expect(within(genreRows[1] as HTMLElement).getByText('Game 1')).toBeTruthy() + expect(within(genreRows[1] as HTMLElement).queryByText('Game 3')).toBeNull() + }) + + 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'), + ]) + + renderMainPage() + + await screen.findByRole('heading', { name: 'Top 3 Games' }) + const regularRow = document.querySelectorAll('.genre-games-row')[0] as HTMLElement + const regularSection = regularRow.closest('section') as HTMLElement + + expect(within(regularRow).getByText('Game 1')).toBeTruthy() + fireEvent.click(within(regularSection).getByRole('button', { name: 'Next' })) + expect(within(regularRow).queryByText('Game 1')).toBeNull() + expect(within(regularRow).getByText('Game 4')).toBeTruthy() + + fireEvent.click(within(regularSection).getByRole('button', { name: 'Previous' })) + expect(within(regularRow).getByText('Game 1')).toBeTruthy() + + fireEvent.change(within(regularSection).getByLabelText('Choose a genre'), { + target: { value: 'Adventure' }, + }) + expect(within(regularRow).getByText('Game 5')).toBeTruthy() + expect(within(regularRow).queryByText('Game 1')).toBeNull() + }) + + 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'), + ]) + + renderMainPage() + + await screen.findByRole('heading', { name: 'Top 3 Games' }) + const discountSection = ( + document.querySelectorAll('.genre-games-row')[1] as HTMLElement + ).closest('section') as HTMLElement + const discountRow = within(discountSection).getByText('Game 2').closest('.genre-games-row') + + expect(discountRow).toBeTruthy() + expect(within(discountRow as HTMLElement).queryByText('Game 1')).toBeNull() + expect(within(discountRow as HTMLElement).queryByText('Game 3')).toBeNull() + expect( + within(discountSection).getByRole('link', { name: 'See More' }).getAttribute('href'), + ).toBe('/searchpage') + + fireEvent.change(within(discountSection).getByLabelText('Choose a genre'), { + target: { value: 'Adventure' }, + }) + expect(within(discountSection).getByText('Game 4')).toBeTruthy() + }) + + it('shows empty states when the database returns no games', async () => { + getGames.mockResolvedValue([]) + + renderMainPage() + + expect(await screen.findByText('No games found.')).toBeTruthy() + expect(screen.getAllByText('No games found for this genre.')).toHaveLength(2) + expect((screen.getAllByLabelText('Choose a genre')[0] as HTMLSelectElement).disabled).toBe( + true, + ) + }) + + it('shows a useful error when fetching games rejects with an Error', async () => { + getGames.mockRejectedValue(new Error('Database unavailable')) + + renderMainPage() + + expect((await screen.findByRole('alert')).textContent).toBe( + 'Could not load games: Database unavailable', + ) + expect(screen.getByText('No games found.')).toBeTruthy() + expect(screen.queryByText('Loading games...')).toBeNull() + }) + + it('uses a fallback message for non-Error fetch failures', async () => { + getGames.mockRejectedValue('unexpected failure') + + renderMainPage() + + expect((await screen.findByRole('alert')).textContent).toBe( + 'Could not load games: Could not load games', + ) + }) + + 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'), + ]) + + renderMainPage() + + await screen.findByRole('heading', { name: 'Top 3 Games' }) + + for (const row of document.querySelectorAll('.genre-games-row')) { + expect(row.children).toHaveLength(1) + } + }) + + it('does not update state after the page unmounts during a fetch', async () => { + let rejectFetch!: (reason: unknown) => void + getGames.mockReturnValue( + new Promise((_resolve, reject) => { + rejectFetch = reject + }), + ) + + const { unmount } = renderMainPage() + unmount() + + await act(async () => { + rejectFetch(new Error('late failure')) + await Promise.resolve() + }) + + expect(getGames).toHaveBeenCalledOnce() + }) +}) From 37150bd354914fc7aec93e2bbba1c450f8fce5f3 Mon Sep 17 00:00:00 2001 From: carmennt Date: Fri, 16 Oct 2026 08:59:29 +0200 Subject: [PATCH 52/76] fix: wishlist diplay works Wishlist works! (it gets the wishlisted games of the user one is logged in with) --- src/css/searchpage.css | 2 +- src/css/wishlistpage.css | 5 +++ src/pages/searchpage.tsx | 4 +-- src/pages/wishlistpage.tsx | 68 +++++++++++++++++--------------------- 4 files changed, 39 insertions(+), 40 deletions(-) diff --git a/src/css/searchpage.css b/src/css/searchpage.css index b7be06b..f160a00 100644 --- a/src/css/searchpage.css +++ b/src/css/searchpage.css @@ -75,7 +75,7 @@ color: var(--primary-accent-color); } -.games-grid { +.games-grid-searchpage { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4rem; diff --git a/src/css/wishlistpage.css b/src/css/wishlistpage.css index 3fac7a0..6ab51ea 100644 --- a/src/css/wishlistpage.css +++ b/src/css/wishlistpage.css @@ -8,6 +8,11 @@ margin: 1rem 0; } +.games-grid-wishlist { + display: grid; + gap: 1rem; +} + .wishlistPagination .MuiPagination-ul { justify-content: center; } diff --git a/src/pages/searchpage.tsx b/src/pages/searchpage.tsx index 6ebfc43..51039f2 100644 --- a/src/pages/searchpage.tsx +++ b/src/pages/searchpage.tsx @@ -122,7 +122,7 @@ export function SearchPage() { ) : games.length === 0 ? (

No games found.

) : ( -
+
{visibleGames.map((game) => { const price = Number(game.price) const salePercent = Number(game.discount) @@ -143,7 +143,7 @@ export function SearchPage() {
)} { diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index 3ce88d6..ce1adbb 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -10,6 +10,7 @@ import type { Game } from '../backend/steam' import PageHeading from '../components/pageheading' import { fetchWishlistFrontend } from '../api/wishlistLogicFrontend' import { WishlistCard } from '../components/WishlistCard' +import { getSavedUser } from '../api/userlogic_frontend' type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' | 'title-asc' @@ -20,11 +21,12 @@ export default function WishlistPage() { const [isLoading, setIsLoading] = useState(true) const [page, setPage] = useState(1) + const userId = Number(getSavedUser()?.id) + // Loades the games useEffect(() => { let cancelled = false - // CHANGE USER_ID - fetchWishlistFrontend(9) + fetchWishlistFrontend(userId) .then((loadedGames) => { if (!cancelled) setWishlistGames(loadedGames) }) @@ -91,43 +93,35 @@ export default function WishlistPage() { } - /> - { - setPage(value) - }} /> - - {error &&

Could not load games: {error}

} - {isLoading ? ( -

Loading games...

- ) : sortedGames.length === 0 ? ( -

No games found.

- ) : ( -
- {visibleGames.map((game) => { - const price = Number(game.price) - const salePercent = Number(game.discount) + + {error &&

Could not load games: {error}

} + {isLoading ? ( +

Loading games...

+ ) : sortedGames.length === 0 ? ( +

No games found.

+ ) : ( +
+ {visibleGames.map((game) => { + const price = Number(game.price) + const salePercent = Number(game.discount) - return ( - - ) - })} -
- )} -
+ return ( + + ) + })} +
+ )} +
Date: Tue, 6 Oct 2026 12:36:15 +0200 Subject: [PATCH 53/76] feat: remove from wishlist at wishlistpage Adding a button so that a user has the option to remove a game from their wishlist. --- src/components/wishlistgamecard.tsx | 48 +++++++++++++++++------------ src/css/wishlistgamecard.css | 28 +++++++++++++++++ 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/components/wishlistgamecard.tsx b/src/components/wishlistgamecard.tsx index bdf6c0f..a333984 100644 --- a/src/components/wishlistgamecard.tsx +++ b/src/components/wishlistgamecard.tsx @@ -1,17 +1,13 @@ -import Card from '@mui/material/Card' -import CardActionArea from '@mui/material/CardActionArea' -import CardContent from '@mui/material/CardContent' -import CardMedia from '@mui/material/CardMedia' -import Chip from '@mui/material/Chip' -import Typography from '@mui/material/Typography' +import { Card, CardActionArea, CardActions, CardContent, CardMedia, Chip, Typography, Button } from '@mui/material' import '../css/wishlistgamecard.css' import type { PassiveGameData } from '../types/game' -type Props = { +type wishlistGamecardProps = { game: PassiveGameData + onRemoveFromWishlist?: (gameId: number) => void } -export default function WishlistGameCard({ game }: Props) { +export default function WishlistGameCard({ game, onRemoveFromWishlist }: wishlistGamecardProps) { const isOnSale = game.salePercent !== undefined && game.salePercent > 0 && game.salePercent <= 100 @@ -28,20 +24,32 @@ export default function WishlistGameCard({ game }: Props) { {game.name} -
- {isOnSale && ( - - )} - - {game.price.toFixed(2)} - -
+ +
+
+ {isOnSale && ( + + )} + + {game.price.toFixed(2)} + +
+ + + +
) } diff --git a/src/css/wishlistgamecard.css b/src/css/wishlistgamecard.css index cfa80b8..5099bf0 100644 --- a/src/css/wishlistgamecard.css +++ b/src/css/wishlistgamecard.css @@ -1,4 +1,6 @@ .wishlistGameCard { + display: flex; + align-items: stretch; width: 100%; border: 1px solid var(--primary-component-color); border-radius: 0.375rem; @@ -9,6 +11,8 @@ .wishlistGameCardAction.MuiCardActionArea-root { display: flex; + flex: 1 1 auto; + min-width: 0; align-items: stretch; text-align: left; } @@ -67,6 +71,25 @@ white-space: nowrap; } +.wishlistGameCardPriceAndRemove { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: flex-end; + justify-content: center; + gap: 0.5rem; + padding: 0.875rem 1rem; +} + +.wishlistGameCardPriceAndRemove .MuiCardActions-root { + padding: 0; +} + +.removeFromWishlistbuttonWishlistPage.MuiButton-root { + border-color: var(--primary---secondary-background-color); + color: var(--secondary-background-color); +} + @media (max-width: 36rem) { .wishlistGameCardMedia { flex-basis: 6rem; @@ -90,4 +113,9 @@ .wishlistGameCardPriceGroup { gap: 0.375rem; } + + .wishlistGameCardPriceAndRemove { + gap: 0.375rem; + padding: 0.75rem; + } } From 9dcdafd9272e741913f9c9635c9c1024a71037d2 Mon Sep 17 00:00:00 2001 From: carmennt Date: Tue, 6 Oct 2026 13:21:19 +0200 Subject: [PATCH 54/76] fix: back navigate and css Moving all mui styling to the wishlistpage.css file. Also adding a back button so a user can navigate back to the last visitied page. --- src/css/wishlistgamecard.css | 4 ++-- src/css/wishlistpage.css | 17 +++++++++++++++++ src/pages/wishlistpage.tsx | 6 ++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/css/wishlistgamecard.css b/src/css/wishlistgamecard.css index 5099bf0..be001b6 100644 --- a/src/css/wishlistgamecard.css +++ b/src/css/wishlistgamecard.css @@ -86,8 +86,8 @@ } .removeFromWishlistbuttonWishlistPage.MuiButton-root { - border-color: var(--primary---secondary-background-color); - color: var(--secondary-background-color); + border-color: var(--primary-accent-color); + color: var(--primary-accent-color); } @media (max-width: 36rem) { diff --git a/src/css/wishlistpage.css b/src/css/wishlistpage.css index a5ae935..b516d8d 100644 --- a/src/css/wishlistpage.css +++ b/src/css/wishlistpage.css @@ -16,3 +16,20 @@ width: min(calc(100% - 2rem), 32rem); } } + +#wishlistContent { + width: 100%; + max-width: 900px; + margin-left: auto; + margin-right: auto; +} + +.wishlistSortControl.MuiFormControl-root { + min-width: 140px; +} + +@media (min-width: 600px) { + .wishlistSortControl.MuiFormControl-root { + min-width: 200px; + } +} \ No newline at end of file diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index f75312c..6c3cb3b 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -7,6 +7,7 @@ import { styled } from '@mui/material/styles' import { Box, FormControl, Select, MenuItem, InputLabel } from '@mui/material' import type { SelectChangeEvent } from '@mui/material' import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import games from '../api/mockdata.json' import bigWalk from '../assets/bigwalk.png' @@ -45,6 +46,7 @@ type SortOption = 'discount-desc' | 'discount-asc' | 'price-asc' | 'price-desc' export default function WishlistPage() { const [sortBy, setSortBy] = useState('discount-desc') + const navigate = useNavigate(); const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) @@ -68,7 +70,7 @@ export default function WishlistPage() { + Sort by } - /> - { - setPage(value) - }} /> - - {error &&

Could not load games: {error}

} - {isLoading ? ( -

Loading games...

- ) : sortedGames.length === 0 ? ( -

No games found.

- ) : ( -
- {visibleGames.map((game) => { - const price = Number(game.price) - const salePercent = Number(game.discount) + + {error &&

Could not load games: {error}

} + {isLoading ? ( +

Loading games...

+ ) : sortedGames.length === 0 ? ( +

No games found.

+ ) : ( +
+ {visibleGames.map((game) => { + const price = Number(game.price) + const salePercent = Number(game.discount) - return ( - - ) - })} -
- )} -
+ return ( + + ) + })} +
+ )} +
Date: Fri, 16 Oct 2026 10:25:44 +0200 Subject: [PATCH 75/76] fix: final wishlist functionality All look good and wishlist works as it should! --- src/api/wishlistLogicFrontend.ts | 21 ++++++--- src/css/searchpage.css | 4 +- src/css/wishlistpage.css | 5 +++ src/pages/searchpage.tsx | 10 ++--- src/pages/wishlistpage.tsx | 74 ++++++++++++++++++++++++-------- 5 files changed, 84 insertions(+), 30 deletions(-) diff --git a/src/api/wishlistLogicFrontend.ts b/src/api/wishlistLogicFrontend.ts index fb2e488..6d39080 100644 --- a/src/api/wishlistLogicFrontend.ts +++ b/src/api/wishlistLogicFrontend.ts @@ -15,7 +15,7 @@ async function gql(query: string, variables: Record): Promis } // Frontend function for calling backend to fetch wishlisted games of a user -export async function fetchWishlistFrontend(userId: number): Promise { +export async function fetchWishlistFrontend(userId: number | null): Promise { const data = await gql<{ wishlist: Game[] }>( `query($userId: Int!) { wishlist(userId: $userId) { @@ -54,8 +54,19 @@ export function toggleGameWishlist( : addToWishlistFrontend(userId, gameId) } -// toggle calls like this: -// const wishlistedIds = new Set(wishlist.map((g) => g.appid)); +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}`) + } +} -// await toggleGameWishlist(userId, game.appid, wishlistedIds.has(game.appid)); -// setWishlist(await fetchWishlist(userId)); \ No newline at end of file +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/css/searchpage.css b/src/css/searchpage.css index e2227c8..19e18db 100644 --- a/src/css/searchpage.css +++ b/src/css/searchpage.css @@ -71,8 +71,8 @@ } .filterOption.MuiMenuItem-root.Mui-selected, -.sortingOption.MuiMenuItem-root.Mui-selected, -.sortingOption.MuiMenuItem-root.Mui-selected:hover { +.sortingOptionSearchpage.MuiMenuItem-root.Mui-selected, +.sortingOptionSearchpage.MuiMenuItem-root.Mui-selected:hover { background-color: var(--secondary-component-color); } diff --git a/src/css/wishlistpage.css b/src/css/wishlistpage.css index 6ab51ea..0dbc43a 100644 --- a/src/css/wishlistpage.css +++ b/src/css/wishlistpage.css @@ -13,6 +13,11 @@ gap: 1rem; } +.sortingOptionWishlist.MuiMenuItem-root.Mui-selected, +.sortingOptionWishlist.MuiMenuItem-root.Mui-selected:hover { + background-color: var(--secondary-component-color); +} + .wishlistPagination .MuiPagination-ul { justify-content: center; } diff --git a/src/pages/searchpage.tsx b/src/pages/searchpage.tsx index 9aaf404..6f17fb4 100644 --- a/src/pages/searchpage.tsx +++ b/src/pages/searchpage.tsx @@ -173,11 +173,11 @@ export function SearchPage() { onChange={handleSortChange} label="Sort by" > - None - Discount: high to low - Price: low to high - Price: high to low - Title: A to Z + None + Discount: high to low + Price: low to high + Price: high to low + Title: A to Z diff --git a/src/pages/wishlistpage.tsx b/src/pages/wishlistpage.tsx index ce1adbb..9415eb6 100644 --- a/src/pages/wishlistpage.tsx +++ b/src/pages/wishlistpage.tsx @@ -8,24 +8,39 @@ import { useState, useEffect, useMemo } from 'react' import type { Game } from '../backend/steam' import PageHeading from '../components/pageheading' -import { fetchWishlistFrontend } from '../api/wishlistLogicFrontend' +import { fetchWishlistFrontend, saveSortOptionWishlist, getSortOptionWishlist } from '../api/wishlistLogicFrontend' import { WishlistCard } from '../components/WishlistCard' -import { getSavedUser } from '../api/userlogic_frontend' +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() + return SORT_OPTIONS.includes(saved as SortOption) ? (saved as SortOption) : 'discount-desc' +} export default function WishlistPage() { - const [sortBy, setSortBy] = useState('discount-desc') + const [sortBy, setSortBy] = useState(getInitialSort) const [wishlistGames, setWishlistGames] = useState([]) const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(true) const [page, setPage] = useState(1) + const { user } = useAuth() - const userId = Number(getSavedUser()?.id) + const userId = user?.id + // Loades the games useEffect(() => { + if(userId == null){ + setWishlistGames([]) + setIsLoading(false) + return undefined + } let cancelled = false + setIsLoading(true) + setError(null) + fetchWishlistFrontend(userId) .then((loadedGames) => { if (!cancelled) setWishlistGames(loadedGames) @@ -42,35 +57,46 @@ export default function WishlistPage() { return () => { cancelled = true } - }, []) + }, [userId]) // 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 - + switch (sortBy) { - case 'discount-desc': return list.sort((a, b) => num(b.discount) - num(a.discount)) 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]) + // Decides games per page and makes a constant for the pagination - const gamesPerPage = 12 + const gamesPerPage = 5 const pageCount = Math.max(1, Math.ceil(sortedGames.length / gamesPerPage)) + const currentPage = Math.min(page, pageCount) + const visibleGames = useMemo(() => { - const start = (page - 1) * gamesPerPage + const start = (currentPage - 1) * gamesPerPage return sortedGames.slice(start, start + gamesPerPage) - }, [sortedGames, page]) + }, [sortedGames, currentPage]) const handleSortChange = (e: SelectChangeEvent) => { setSortBy(e.target.value as SortOption) + setPage(1) + } + + const handleRemoved = (gameId: number) => { + setWishlistGames((games) => games.filter((game) => game.appid !== gameId)) } + return (
@@ -85,21 +111,32 @@ export default function WishlistPage() { label="Sort by" onChange={handleSortChange} > - Discount: high to low - Discount: low to high - Price: low to high - Price: high to low - Title: A to Z + Discount: high to low + Discount: low to high + Price: low to high + Price: high to low + Title: A to Z } /> + { + setPage(value) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }} + /> {error &&

Could not load games: {error}

} {isLoading ? (

Loading games...

+ ) : !user ? ( +

Sign in to see your wishlist

) : sortedGames.length === 0 ? ( -

No games found.

+

No games found on your wishlist.

) : (
{visibleGames.map((game) => { @@ -116,6 +153,7 @@ export default function WishlistPage() { image: game.img, salePercent: Number.isFinite(salePercent) ? salePercent : 0, }} + onRemoveFromWishlist={handleRemoved} /> ) })} @@ -125,7 +163,7 @@ export default function WishlistPage() { { setPage(value) window.scrollTo({ top: 0, behavior: 'smooth' }) From a40796d7260d4730283cda4b9536b4b5858aa232 Mon Sep 17 00:00:00 2001 From: carmennt Date: Fri, 16 Oct 2026 10:42:43 +0200 Subject: [PATCH 76/76] fix: wishlistGameCard now links to activeCard --- src/components/wishlistgamecard.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/wishlistgamecard.tsx b/src/components/wishlistgamecard.tsx index 729eb8c..397208c 100644 --- a/src/components/wishlistgamecard.tsx +++ b/src/components/wishlistgamecard.tsx @@ -4,6 +4,7 @@ import type { PassiveGameData } from '../types/game' import { removeFromWishlistFrontend } from '../api/wishlistLogicFrontend' import { getSavedUser } from '../api/userlogic_frontend' import { useState } from 'react' +import { Link } from 'react-router-dom' type wishlistGamecardProps = { game: PassiveGameData @@ -38,7 +39,8 @@ export default function WishlistGameCard({ game, onRemoveFromWishlist }: wishlis return ( - +