diff --git a/.gitignore b/.gitignore index db127d7..b07455c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,9 @@ dist-ssr *.njsproj *.sln *.sw? -*.env # Coverage Coverage/ #Env file -.env +.env \ No newline at end of file diff --git a/src/api/wishlistLogicFrontend.ts b/src/api/wishlistLogicFrontend.ts new file mode 100644 index 0000000..6d39080 --- /dev/null +++ b/src/api/wishlistLogicFrontend.ts @@ -0,0 +1,72 @@ +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/backend/server.ts b/src/backend/server.ts index e601a09..2ecb143 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -4,9 +4,21 @@ 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' // 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 { @@ -28,11 +40,14 @@ const typeDefs = `#graphql type Query { games(limit: Int = 500, offset: Int = 0): [Game!]! + wishlist(userId: Int!): [Game!]! game(appid: Int!): Game genres: [String!]! } - + 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! } @@ -48,21 +63,25 @@ 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 + }, game: async (_parent: unknown, { appid }: { appid: number }): Promise => { const { rows } = await pool.query( `SELECT game_id AS appid, @@ -82,6 +101,14 @@ const resolvers = { genres: () => fetchGenres(), }, 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 new file mode 100644 index 0000000..3542760 --- /dev/null +++ b/src/backend/wishlistLogic.ts @@ -0,0 +1,31 @@ +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/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/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/passivegamecard.tsx b/src/components/passivegamecard.tsx index 110c881..8e0762a 100644 --- a/src/components/passivegamecard.tsx +++ b/src/components/passivegamecard.tsx @@ -9,14 +9,20 @@ import type { PassiveGameData } from '../types/game' type Props = { game: PassiveGameData + rank?: number } -export default function PassiveGameCard({ game }: Props) { +export default function PassiveGameCard({ game, rank }: Props) { const isOnSale = game.salePercent !== undefined && game.salePercent > 0 && game.salePercent <= 100 return ( - + + {rank !== undefined && ( + + #{rank} + + )}
{isOnSale ? `-${game.salePercent}%` : ''}
- {game.price.toFixed(2)} + {game.price.toFixed(2)} $ diff --git a/src/components/wishlistgamecard.tsx b/src/components/wishlistgamecard.tsx index bdf6c0f..397208c 100644 --- a/src/components/wishlistgamecard.tsx +++ b/src/components/wishlistgamecard.tsx @@ -1,23 +1,46 @@ -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' +import { removeFromWishlistFrontend } from '../api/wishlistLogicFrontend' +import { getSavedUser } from '../api/userlogic_frontend' +import { useState } from 'react' +import { Link } from 'react-router-dom' -type Props = { +type wishlistGamecardProps = { game: PassiveGameData + onRemoveFromWishlist?: (gameId: number) => void } -export default function WishlistGameCard({ game }: Props) { +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 ( - + {game.name} -
- {isOnSale && ( - - )} - - {game.price.toFixed(2)} - -
+
+
+
+ {isOnSale && ( + + )} + + {game.price.toFixed(2)} + +
+ + + + {error &&

{error}

} +
) } diff --git a/src/css/mainpage.css b/src/css/mainpage.css index b981a05..7a286c9 100644 --- a/src/css/mainpage.css +++ b/src/css/mainpage.css @@ -5,38 +5,324 @@ } .main-page { + display: flex; + flex: 1; + flex-direction: column; + gap: 2rem; width: min(calc(100% - 3rem), 68rem); margin: 0 auto; padding: 1.75rem 0 clamp(2rem, 8vh, 4rem); } -.featured-games-title { +.main-page-section-heading { display: flex; - align-items: center; min-height: 2.5rem; - margin: 0 0 1.5rem; + align-items: center; + margin-bottom: 0.9rem; +} + +.main-page-section { + margin-inline: 3.25rem; +} + +.main-page-section-heading h1, +.main-page-section-heading h2 { + margin: 0; font-size: 2rem; line-height: 1.2; } +.main-page-section-content.MuiCard-root { + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: clamp(1rem, 3vw, 1.75rem); +} + .featured-games-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + grid-template-rows: repeat(2, auto); + align-items: stretch; + column-gap: 1.25rem; + row-gap: 0.875rem; +} + +.featured-games-primary { + grid-row: span 2; + min-height: 0; +} + +.featured-game-card { + position: relative; + width: 100%; + min-width: 0; +} + +.featured-game-card > .passiveGameCard { + width: 100%; +} + +.featured-games-primary > .passiveGameCard { + height: 100%; + min-height: 0; +} + +.featured-games-primary.featured-game-card .passiveGameCardAction { + flex: 1 1 auto; + height: 100%; + min-height: 0; +} + +.featured-games-primary.featured-game-card .passiveGameCardMedia { + flex: 1 1 auto; + min-height: 0; + height: auto; +} + +.featured-game-card .passiveGameCardContent.MuiCardContent-root { + padding-bottom: 0.25rem; +} + +.genre-controls { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.genre-autocomplete { + width: min(100%, var(--genre-filter-width, 11rem)); +} + +.genre-autocomplete .MuiOutlinedInput-root { + background-color: var(--primary-background-color); +} + +.genre-autocomplete .MuiOutlinedInput-notchedOutline { + border-color: var(--primary-component-color); +} + +.genre-autocomplete .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline, +.genre-autocomplete .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: var(--primary-accent-color); +} + +.genre-games-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: stretch; + gap: 1.25rem; +} + +.genre-see-more { + display: flex; + justify-content: flex-end; + margin: -0.5rem 0 0.5rem; +} + +.genre-see-more .MuiButton-root { + color: var(--primary-text-color); +} + +.genre-carousel-controls { + display: flex; + width: 100%; + gap: 1rem; + margin-top: 1.5rem; +} + +.genre-carousel-controls .MuiButton-root { + flex: 1 1 0; + min-width: 0; + min-height: 3.5rem; + background-color: var(--primary-component-color); + color: var(--primary-text-color); + font-size: 1.125rem; +} + +.genre-carousel-controls .MuiButton-root:hover { + background-color: var(--primary-accent-color); +} + +.genre-carousel-controls .MuiButton-root.Mui-disabled { + background-color: var(--secondary-component-color); + color: rgb(0 0 0 / 45%); +} + +.popular-reviews-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; gap: 1.25rem; } +.popular-review { + display: flex; + flex-direction: column; + min-height: 10rem; + padding: 1.1rem; + border: 1px solid var(--primary-component-color); + border-radius: 0.75rem; + background-color: var(--primary-background-color); + box-shadow: 0 2px 8px rgb(0 0 0 / 8%); +} + +.popular-review-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.popular-review-game, +.popular-review-author { + display: block; +} + +.popular-review-game { + font-size: 1.1rem; + font-weight: 700; +} + +.popular-review-author { + margin-top: 0.25rem; + color: #46545b; + font-size: 0.85rem; +} + +.popular-review-rating { + flex: 0 0 auto; + padding: 0.35rem 0.55rem; + border-radius: 0.5rem; + background-color: var(--secondary-component-color); + font-size: 0.85rem; + font-weight: 700; +} + +.popular-review-text { + margin: 1rem 0; + line-height: 1.55; +} + +.popular-review-link { + width: fit-content; + margin-top: auto; + color: #005d8c; + font-weight: 700; + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; +} + +.popular-review-link:focus-visible { + outline: 2px solid var(--secondary-background-color); + outline-offset: 3px; +} + +@media (max-width: 56rem) { + .genre-games-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .featured-games-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: auto; + row-gap: 1.25rem; + } + + .featured-games-primary { + grid-column: 1 / -1; + grid-row: auto; + height: auto; + } + + .featured-games-primary > .passiveGameCard { + height: auto; + } + + .featured-games-primary.featured-game-card .passiveGameCardAction { + flex: 0 0 auto; + height: auto; + min-height: 0; + } + + .featured-games-primary.featured-game-card .passiveGameCardMedia { + flex: 0 0 auto; + aspect-ratio: 16 / 9; + } + + .featured-games-primary.featured-game-card .passiveGameCardAction { + flex: 0 0 auto; + height: auto; + min-height: 0; + } + + .featured-games-primary.featured-game-card .passiveGameCardMedia { + flex: 0 0 auto; + } +} + +@media (min-width: 56.01rem) { + .featured-game-secondary .passiveGameCardMedia { + aspect-ratio: 2 / 1; + } + + .featured-game-secondary .passiveGameCardContent.MuiCardContent-root { + flex-basis: 3.5rem; + padding-inline: 0.75rem; + } + + .featured-game-secondary .GameTitle { + height: 1.8em; + font-size: 1rem; + } + + .featured-game-secondary .passiveGameCardTitle { + font-size: 1rem; + } + + .featured-game-secondary .GamePrice { + flex-direction: row; + align-items: center; + gap: 0.5rem; + } +} + @media (max-width: 43.75rem) { .main-page { width: min(calc(100% - 2rem), 32rem); - padding: 1.25rem 0 clamp(1.5rem, 6vh, 2.5rem); + padding: clamp(1.5rem, 6vh, 2.5rem) 0; + } + + .main-page-section { + margin-inline: 0; } - .featured-games-title { + .main-page-section-heading h1, + .main-page-section-heading h2 { font-size: 1.75rem; } .featured-games-grid { + grid-template-columns: minmax(0, 1fr); + } + + .featured-game-card > .passiveGameCard { + width: min(100%, 22rem); + margin-inline: auto; + } + + .genre-games-row { + grid-template-columns: 1fr; + } + + .popular-reviews-grid { grid-template-columns: 1fr; - gap: 0.875rem; + } +} + +@media (max-width: 30rem) { + .genre-autocomplete { + width: 100%; } } diff --git a/src/css/passivegamecard.css b/src/css/passivegamecard.css index b30c15b..2760566 100644 --- a/src/css/passivegamecard.css +++ b/src/css/passivegamecard.css @@ -1,45 +1,101 @@ .passiveGameCard { width: min(100%, 22rem); + justify-self: center; + display: flex; + flex-direction: column; + overflow: hidden; + container-type: inline-size; background-color: var(--primary-background-color); color: var(--primary-text-color); box-shadow: 2px 3px 6px var(--box-shadow-color) !important; + transition: + transform 180ms ease, + box-shadow 180ms ease; +} + +.rankedPassiveGameCard { + position: relative; +} + +.passiveGameCardRank { + position: absolute; + z-index: 1; + top: 0; + left: 0; + padding: 0.5rem 0.75rem; + border-radius: 0 0 0.75rem 0; + background-color: rgb(255 255 255 / 85%); + font-size: 1.1rem; + font-weight: 700; + pointer-events: none; } -.passiveGameCardAction { +.passiveGameCard:hover, +.passiveGameCard:focus-within { + transform: translateY(-4px); + box-shadow: 0 8px 18px rgb(0 0 0 / 20%) !important; +} + +.passiveGameCardAction.MuiCardActionArea-root { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: stretch; width: 100%; } .passiveGameCardMedia { - height: 18vh; - min-height: 10rem; - max-height: 15rem; + flex: 0 0 auto; + width: 100%; + aspect-ratio: 16 / 9; object-fit: cover; + object-position: center; } .passiveGameCardContent.MuiCardContent-root { - display: flex !important; - flex-direction: row !important; - justify-content: space-between !important; - align-items: center !important; - gap: 1rem; - padding: 1rem; + box-sizing: border-box; + flex: 0 0 3.5rem; + min-height: 0; + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto; + align-items: flex-start !important; + gap: 0.5rem; + padding: 0.25rem 1rem; background-color: var(--primary-component-color); color: var(--primary-text-color); } .GameTitle { - flex: 1 1 auto; + position: relative; min-width: 0; + height: 2.2em; + overflow: hidden; + font-size: 1.25rem; text-align: left; } +.GameTitle::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 0.6em; + background: linear-gradient( + to bottom, + transparent, + color-mix(in srgb, var(--primary-component-color) 90%, transparent) + ); + content: ''; + pointer-events: none; +} + .GamePrice { display: flex; - flex: 0 0 auto; + min-width: 0; + align-self: flex-start; align-items: center; justify-content: flex-end; gap: 0.5rem; - margin-left: auto; text-align: right; white-space: nowrap; } @@ -69,9 +125,34 @@ font-size: 1.25rem; font-weight: 600; line-height: 1.3; + overflow-wrap: normal; + word-break: normal; } .passiveGameCardPrice { margin: 0; color: var(--primary-text-color); } + +@container (max-width: 18rem) { + .passiveGameCardContent.MuiCardContent-root { + flex-basis: 4rem; + } + + .GamePrice { + flex-direction: column-reverse; + align-items: flex-end; + gap: 0.125rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .passiveGameCard { + transition: none; + } + + .passiveGameCard:hover, + .passiveGameCard:focus-within { + transform: none; + } +} diff --git a/src/css/searchpage.css b/src/css/searchpage.css index 09affa2..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); } @@ -81,7 +81,7 @@ background-color: var(--primary-component-color); } -.games-grid { +.games-grid-searchpage { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4rem; diff --git a/src/css/wishlistgamecard.css b/src/css/wishlistgamecard.css index cfa80b8..be001b6 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-accent-color); + color: var(--primary-accent-color); +} + @media (max-width: 36rem) { .wishlistGameCardMedia { flex-basis: 6rem; @@ -90,4 +113,9 @@ .wishlistGameCardPriceGroup { gap: 0.375rem; } + + .wishlistGameCardPriceAndRemove { + gap: 0.375rem; + padding: 0.75rem; + } } diff --git a/src/css/wishlistpage.css b/src/css/wishlistpage.css index a5ae935..0dbc43a 100644 --- a/src/css/wishlistpage.css +++ b/src/css/wishlistpage.css @@ -4,15 +4,41 @@ min-height: 100dvh; } -.wishlistContent { - box-sizing: border-box; +.wishlistPagination { + margin: 1rem 0; +} + +.games-grid-wishlist { + display: grid; + 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; +} + +.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); } } + +.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/mainpage.tsx b/src/pages/mainpage.tsx index 15cf303..ff14a13 100644 --- a/src/pages/mainpage.tsx +++ b/src/pages/mainpage.tsx @@ -2,20 +2,312 @@ import '../css/mainpage.css' import Header from '../components/header' import Footer from '../components/footer' import PassiveGameCard from '../components/passivegamecard' -import { games } from '../api/games' +import { WishlistCard } from '../components/WishlistCard' +import { getGames } from '../api/gameClient' +import type { Game } from '../backend/steam' +import { Link } from 'react-router-dom' +import type { CSSProperties } from 'react' +import { useEffect, useMemo, useState } from 'react' +import Autocomplete from '@mui/material/Autocomplete' +import Button from '@mui/material/Button' +import TextField from '@mui/material/TextField' +import useMediaQuery from '@mui/material/useMediaQuery' + +type PageGame = { + id: number + name: string + price: number + genre: string[] + image: string + salePercent: number +} + +function toPageGame(game: Game): PageGame { + const price = Number(game.price) + const salePercent = Number(game.discount) + const genres = + typeof game.genre === 'string' + ? game.genre + .split(',') + .map((genre) => genre.trim()) + .filter(Boolean) + : [] + + return { + id: game.appid, + name: game.name, + price: Number.isFinite(price) ? price : 0, + genre: genres, + image: game.img ?? '', + salePercent: Number.isFinite(salePercent) ? salePercent : 0, + } +} + +type GenreGamesSectionProps = { + title: string + games: PageGame[] + genres: string[] + isLoading: boolean + discountedOnly?: boolean + showSeeMore?: boolean +} + +function GenreGamesSection({ + title, + games, + genres, + isLoading, + discountedOnly = false, + showSeeMore = false, +}: GenreGamesSectionProps) { + const [selectedGenre, setSelectedGenre] = useState('') + const [firstGameIndex, setFirstGameIndex] = useState(0) + const isPhone = useMediaQuery('(max-width: 43.75rem)') + const isTablet = useMediaQuery('(max-width: 56rem)') + const visibleGameCount = isPhone ? 1 : isTablet ? 2 : 3 + const activeGenre = genres.includes(selectedGenre) ? selectedGenre : (genres[0] ?? '') + const longestGenreLength = Math.max(0, ...genres.map((genre) => genre.length)) + const genreGames = games + .filter( + (game) => + game.genre.includes(activeGenre) && + (!discountedOnly || (game.salePercent > 0 && game.salePercent <= 100)), + ) + .slice(0, 10) + const maxStartIndex = Math.max(0, genreGames.length - visibleGameCount) + const startIndex = Math.min(firstGameIndex, maxStartIndex) + const visibleGames = genreGames.slice(startIndex, startIndex + visibleGameCount) + + const selectGenre = (genre: string) => { + setSelectedGenre(genre) + setFirstGameIndex(0) + } + + return ( +
+
+

{title}

+
+ +
+ { + selectGenre(genre ?? '') + }} + disabled={genres.length === 0} + renderInput={(params) => ( + + )} + /> +
+ {showSeeMore && ( +
+ {/* Will add current genre and discount filter that links to SearchPage here */} + +
+ )} +
+ {isLoading ? ( +

Loading games...

+ ) : visibleGames.length > 0 ? ( + visibleGames.map((game) => ) + ) : ( +

No games found for this genre.

+ )} +
+
+ + +
+
+
+ ) +} export default function MainPage() { + const [databaseGames, setDatabaseGames] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + getGames() + .then((loadedGames) => { + if (!cancelled) setDatabaseGames(loadedGames) + }) + .catch((fetchError: unknown) => { + if (!cancelled) { + setError( + fetchError instanceof Error ? fetchError.message : 'Could not load games', + ) + } + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + + const pageGames = useMemo(() => databaseGames.map(toPageGame), [databaseGames]) + const featuredGames = pageGames.slice(0, 3) + const genresToFeature = useMemo( + () => [...new Set(pageGames.flatMap((game) => game.genre))].sort(), + [pageGames], + ) + // Temporary placeholder reviews to showcase future functionality, + // attached to fetched games, without adding review data to the actual game cards. + const reviewSamples = [ + { + gameIndex: 0, + author: 'Reviewer1', + rating: '4 / 5', + text: 'A beautiful new world to get lost in, with wonderful, precise combat and secrets around every corner!', + }, + { + gameIndex: 1, + author: 'Reviewer2', + rating: '3 / 5', + text: 'The best kind of co-op chaos! Every trip feels like a story you will be telling for a while.', + }, + ] + const recentReviews = reviewSamples.flatMap((review) => { + const game = pageGames[review.gameIndex] + return game ? [{ ...review, game }] : [] + }) + return (
-
-

Featured Games

-
- {games.slice(0, 3).map((game) => ( - - ))} + {error && ( +

+ Could not load games: {error} +

+ )} +
+
+

Top 3 Games

+
+ + {isLoading ? ( +

Loading games...

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

No games found.

+ ) : ( +
+ {featuredGames[0] && ( +
+ +
+ )} + {featuredGames[1] && ( +
+ +
+ )} + {featuredGames[2] && ( +
+ +
+ )} +
+ )} +
+
+ + + + +
+
+

Recent reviews

+ + {isLoading ? ( +

Loading reviews...

+ ) : ( +
+ {recentReviews.map((review) => ( +
+
+
+ + {review.game.name} + + + Review by {review.author} + +
+ + {review.rating} + +
+

“{review.text}”

+ + See this game + +
+ ))} +
+ )} +