From 0127f31975fd6d84c9d7f112b3a475a9321b1591 Mon Sep 17 00:00:00 2001 From: Alberto Date: Mon, 7 Sep 2026 18:12:23 +0200 Subject: [PATCH 1/7] feat(favorite): create toggle component with local storage * Build Favorite UI component with click handler * Implement local storage sync directly within component state * Add plain CSS for interactive hover states --- src/components/Favorite/Favorite.css | 25 +++++++++++++++ src/components/Favorite/Favorite.tsx | 46 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/components/Favorite/Favorite.css create mode 100644 src/components/Favorite/Favorite.tsx diff --git a/src/components/Favorite/Favorite.css b/src/components/Favorite/Favorite.css new file mode 100644 index 0000000..70c97a4 --- /dev/null +++ b/src/components/Favorite/Favorite.css @@ -0,0 +1,25 @@ +.favorite-button { + background: transparent; + border: none; + font-size: 1.5rem; + cursor: pointer; + padding: 0.5rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease-in-out; +} + +.favorite-button:hover { + transform: scale(1.2); +} + +.favorite-button:focus-visible { + outline: 2px solid #0056b3; + outline-offset: 2px; +} + +.favorite-button:active { + transform: scale(0.9); +} diff --git a/src/components/Favorite/Favorite.tsx b/src/components/Favorite/Favorite.tsx new file mode 100644 index 0000000..408588e --- /dev/null +++ b/src/components/Favorite/Favorite.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import "./Favorite.css"; + +type FavoriteProps = { + pokemonId: number; +}; + +export function Favorite({ pokemonId }: FavoriteProps) { + // 1. Lazy initialization from localStorage + const [favorites, setFavorites] = useState(() => { + try { + const storedFavorites = window.localStorage.getItem("favorites"); + return storedFavorites ? JSON.parse(storedFavorites) : []; + } catch (error) { + console.error("Error reading localStorage", error); + return []; + } + }); + + const isFavorite = favorites.includes(pokemonId); + + // 2. The click handler syncing State and Storage + const toggleFavorite = () => { + let updatedFavorites: number[]; + + if (isFavorite) { + updatedFavorites = favorites.filter((id) => id !== pokemonId); + } else { + updatedFavorites = [...favorites, pokemonId]; + } + + setFavorites(updatedFavorites); + window.localStorage.setItem("favorites", JSON.stringify(updatedFavorites)); + }; + + // 3. Accessible UI rendering + return ( + + ); +} From c4db298f354201f32a4790c4622ee2a3b97bf672 Mon Sep 17 00:00:00 2001 From: Alberto Date: Mon, 7 Sep 2026 18:12:45 +0200 Subject: [PATCH 2/7] feat(pokedex): add ID search input and integrate favorite toggle * Add controlled number input for direct ID searching * Bind input state to TanStack Query for reactive fetching * Render Favorite component inside the main view * Validate input to prevent negative numbers --- src/App.tsx | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 79c5234..4d00613 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,12 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { getPokemonByID } from "./api/pokemon"; -import { PokemonCard } from "./components/PokemonCard"; +import { PokemonCard } from "./components/PokemonCard/PokemonCard"; +import { Favorite } from "./components/Favorite/Favorite"; export default function App() { - const [pokemonId, setPokemonId] = useState(Math.ceil(Math.random() * 493)); + // Start at a default ID + const [pokemonId, setPokemonId] = useState(1); const query = useQuery({ queryKey: ["pokemon", pokemonId], @@ -19,6 +21,14 @@ export default function App() { setPokemonId((prev) => prev + 1); }; + const handleInputChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value, 10); + // Ensure the user cannot enter negative numbers or invalid text + if (!isNaN(value) && value > 0) { + setPokemonId(value); + } + }; + return (
-
+
+ + +
{query.isPending &&

Loading...

} {query.isError &&

Failed to load Pokemon

} {query.data && } +
); } From 6f0af18d8d6872086609496fd8386295f6052df9 Mon Sep 17 00:00:00 2001 From: Alberto Date: Mon, 7 Sep 2026 19:13:12 +0200 Subject: [PATCH 3/7] feat(favorites): create presentation component for favorite list * Build purely presentational list view * Add temporary dummy data for UI testing * Add CSS layout for list items --- .../FavoritePokemon/FavoritePokemon.css | 46 +++++++++++++ .../FavoritePokemon/FavoritePokemon.tsx | 68 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/components/FavoritePokemon/FavoritePokemon.css create mode 100644 src/components/FavoritePokemon/FavoritePokemon.tsx diff --git a/src/components/FavoritePokemon/FavoritePokemon.css b/src/components/FavoritePokemon/FavoritePokemon.css new file mode 100644 index 0000000..3b8e517 --- /dev/null +++ b/src/components/FavoritePokemon/FavoritePokemon.css @@ -0,0 +1,46 @@ +.favorite-container { + width: 100%; + max-width: 400px; + margin: 0 auto; +} + +.favorite-list { + list-style-type: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.favorite-item { + display: flex; + align-items: center; + background-color: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 8px 16px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.favorite-sprite { + width: 64px; + height: 64px; + margin-right: 16px; +} + +.favorite-info { + display: flex; + flex-direction: column; +} + +.dex-number { + font-size: 0.85rem; + color: #6c757d; +} + +.pokemon-name { + font-size: 1.1rem; + font-weight: bold; + text-transform: capitalize; +} diff --git a/src/components/FavoritePokemon/FavoritePokemon.tsx b/src/components/FavoritePokemon/FavoritePokemon.tsx new file mode 100644 index 0000000..6c474f5 --- /dev/null +++ b/src/components/FavoritePokemon/FavoritePokemon.tsx @@ -0,0 +1,68 @@ +import "./FavoritePokemon.css"; + +type PokemonData = { + id: number; + name: string; + sprites: { + front_default: string; + }; +}; + +type FavoritePokemonProps = { + pokemonList?: PokemonData[]; +}; + +const DUMMY_DATA: PokemonData[] = [ + { + id: 1, + name: "bulbasaur", + sprites: { + front_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png", + }, + }, + { + id: 4, + name: "charmander", + sprites: { + front_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/4.png", + }, + }, + { + id: 25, + name: "pikachu", + sprites: { + front_default: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png", + }, + }, +]; + +export function FavoritePokemon({ pokemonList }: FavoritePokemonProps) { + const listToRender = + pokemonList && pokemonList.length > 0 ? pokemonList : DUMMY_DATA; + + return ( +
+

My Favorites

+
    + {listToRender.map((pokemon) => ( +
  • + {pokemon.name} +
    + + #{pokemon.id.toString().padStart(3, "0")} + + {pokemon.name} +
    +
  • + ))} +
+
+ ); +} From 0a4a4a471b720699c8fcc943ed4e170e7fef5b96 Mon Sep 17 00:00:00 2001 From: Alberto Date: Tue, 8 Sep 2026 12:31:45 +0200 Subject: [PATCH 4/7] feat: add strict typing and display general pokemon info --- src/components/PokemonCard/PokemonCard.tsx | 46 +++++++++++++++++++--- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/components/PokemonCard/PokemonCard.tsx b/src/components/PokemonCard/PokemonCard.tsx index 2ae336d..84489fa 100644 --- a/src/components/PokemonCard/PokemonCard.tsx +++ b/src/components/PokemonCard/PokemonCard.tsx @@ -1,7 +1,17 @@ import "./PokemonCard.css"; +type PokemonData = { + name: string; + sprites: { front_default: string }; + stats: Array<{ base_stat: number; stat: { name: string } }>; + height: number; + weight: number; + types: Array<{ type: { name: string } }>; + abilities: Array<{ ability: { name: string }; is_hidden: boolean }>; +}; + type PokemonCardProps = { - pokemon: any; + pokemon: PokemonData; }; export function PokemonCard({ pokemon }: PokemonCardProps) { @@ -20,12 +30,38 @@ export function PokemonCard({ pokemon }: PokemonCardProps) { let total = 0; return ( -
+

{displayName}

- - + {`${displayName} + +
+
+ Type: +
+ {pokemon.types.map((t) => ( + + {t.type.name} + + ))} +
+
+
+ Height: {pokemon.height / 10} m +
+
+ Weight: {pokemon.weight / 10} kg +
+
+ Abilities:{" "} + {pokemon.abilities + .map((a) => a.ability.name + (a.is_hidden ? " (Hidden)" : "")) + .join(", ")} +
+
+ +
- {pokemon.stats.map((stat: any) => { + {pokemon.stats.map((stat) => { const base = stat.base_stat; total += base; From 70ccc4ac1ff53c66c0b335af7df521b626a17612 Mon Sep 17 00:00:00 2001 From: Alberto Date: Tue, 8 Sep 2026 12:33:03 +0200 Subject: [PATCH 5/7] style: scope component css and format general info box --- src/components/PokemonCard/PokemonCard.css | 68 ++++++++++++++++------ 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/src/components/PokemonCard/PokemonCard.css b/src/components/PokemonCard/PokemonCard.css index a803b44..4a8a922 100644 --- a/src/components/PokemonCard/PokemonCard.css +++ b/src/components/PokemonCard/PokemonCard.css @@ -1,4 +1,4 @@ -div { +.pokemon-card { display: flex; flex-direction: column; align-items: center; @@ -9,38 +9,72 @@ div { background: #fff; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + margin: 0 auto; + max-width: 500px; } -h1 { +.pokemon-card h1 { text-align: center; margin-bottom: 10px; } -img { +.pokemon-card img { display: block; margin: 0 auto 20px auto; width: 120px; height: 120px; } -table { +.general-info { + width: 100%; + background: #f8f9fa; + padding: 16px; + border-radius: 8px; + margin-bottom: 24px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.info-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +} + +.type-badges { + display: flex; + gap: 6px; +} + +.type-badge { + background: #333; + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + text-transform: capitalize; +} + +.stats-table { display: grid; grid-template-columns: auto auto 1fr auto auto; width: 100%; border-collapse: collapse; } -tbody, -tfoot, -tr { +.stats-table tbody, +.stats-table tfoot, +.stats-table tr { display: contents; } -tr { +.stats-table tr { border-bottom: 1px solid #eee; } -th { +.stats-table th { padding: 8px 4px; font-weight: normal; color: #666; @@ -52,13 +86,13 @@ th { white-space: nowrap; } -th.cell-num:nth-child(1) { +.stats-table th.cell-num:nth-child(1) { text-align: left; color: #666; font-weight: 500; } -th.cell-num:nth-child(2) { +.stats-table th.cell-num:nth-child(2) { font-weight: bold; color: #333; } @@ -79,31 +113,31 @@ th.cell-num:nth-child(2) { padding: 6px; } -tfoot tr { +.stats-table tfoot tr { border-bottom: none; } -tfoot th { +.stats-table tfoot th { color: #333; font-weight: bold !important; } -tfoot th:nth-child(1) { +.stats-table tfoot th:nth-child(1) { grid-column: 1; text-align: left; } -tfoot th:nth-child(2) { +.stats-table tfoot th:nth-child(2) { grid-column: 2; text-align: right; } -tfoot th:nth-child(3) { +.stats-table tfoot th:nth-child(3) { grid-column: 4; text-align: right; } -tfoot th:nth-child(4) { +.stats-table tfoot th:nth-child(4) { grid-column: 5; text-align: right; } From 37b02c342b6c17c0445706575bf0cacf04c0bf2b Mon Sep 17 00:00:00 2001 From: Alberto Date: Tue, 8 Sep 2026 12:33:09 +0200 Subject: [PATCH 6/7] test: add unit and snapshot tests for pokemon card --- .../PokemonCard/PokemonCard.test.tsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/components/PokemonCard/PokemonCard.test.tsx b/src/components/PokemonCard/PokemonCard.test.tsx index bd2b506..b5a816f 100644 --- a/src/components/PokemonCard/PokemonCard.test.tsx +++ b/src/components/PokemonCard/PokemonCard.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react"; -import { expect, test } from "vitest"; +import { describe, expect, test } from "vitest"; import { PokemonCard } from "./PokemonCard"; const mockPokemon = { @@ -8,6 +8,10 @@ const mockPokemon = { front_default: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png", }, + height: 4, + weight: 60, + types: [{ type: { name: "electric" } }], + abilities: [{ ability: { name: "static" }, is_hidden: false }], stats: [ { base_stat: 35, stat: { name: "hp" } }, { base_stat: 55, stat: { name: "attack" } }, @@ -18,7 +22,19 @@ const mockPokemon = { ], }; -test("renders pokemon name", () => { - render(); - expect(screen.getByText("Pikachu")).toBeInTheDocument(); +describe("PokemonCard Component", () => { + test("renders pokemon name and general information", () => { + render(); + + expect(screen.getByText("Pikachu")).toBeDefined(); + expect(screen.getByText("electric")).toBeDefined(); + expect(screen.getByText("static")).toBeDefined(); + expect(screen.getByText("0.4 m")).toBeDefined(); + expect(screen.getByText("6 kg")).toBeDefined(); + }); + + test("matches snapshot", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); }); From d9bce07359d5ddfe15889a231bfd44adb6f262b9 Mon Sep 17 00:00:00 2001 From: Alberto Date: Tue, 8 Sep 2026 13:53:53 +0200 Subject: [PATCH 7/7] feat: create PokemonList component with styles and tests --- src/components/PokemonList/PokemonList.css | 65 +++++++++++++++++++ .../PokemonList/PokemonList.test.tsx | 44 +++++++++++++ src/components/PokemonList/PokemonList.tsx | 54 +++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 src/components/PokemonList/PokemonList.css create mode 100644 src/components/PokemonList/PokemonList.test.tsx create mode 100644 src/components/PokemonList/PokemonList.tsx diff --git a/src/components/PokemonList/PokemonList.css b/src/components/PokemonList/PokemonList.css new file mode 100644 index 0000000..691a86d --- /dev/null +++ b/src/components/PokemonList/PokemonList.css @@ -0,0 +1,65 @@ +.pokemon-list { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + max-width: 300px; + max-height: 500px; + overflow-y: auto; + background: #f8f9fa; + padding: 12px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.pokemon-list-empty { + padding: 24px; + text-align: center; + color: #666; + font-family: sans-serif; +} + +.pokemon-list-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + font-family: sans-serif; +} + +.pokemon-list-item:hover { + background: #f0f0f0; + transform: translateY(-1px); +} + +.pokemon-list-item.selected { + border-color: #007bff; + background: #e6f2ff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); +} + +.pokemon-list-item img { + width: 40px; + height: 40px; + object-fit: contain; +} + +.pokemon-id { + font-size: 12px; + color: #888; + font-weight: bold; + min-width: 35px; +} + +.pokemon-name { + font-size: 14px; + font-weight: 500; + color: #333; + text-transform: capitalize; +} diff --git a/src/components/PokemonList/PokemonList.test.tsx b/src/components/PokemonList/PokemonList.test.tsx new file mode 100644 index 0000000..a6dacd9 --- /dev/null +++ b/src/components/PokemonList/PokemonList.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, test, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PokemonList } from "./PokemonList"; + +const mockPokemons = [ + { id: 25, name: "pikachu", sprite: "pikachu.png" }, + { id: 4, name: "charmander", sprite: "charmander.png" }, + { id: 1, name: "bulbasaur", sprite: "bulbasaur.png" }, +]; + +describe("PokemonList Component", () => { + test("renders sorted list of pokemon", () => { + render( {}} />); + + const items = screen.getAllByRole("button"); + expect(items).toHaveLength(3); + + // Verifies sorting by ID (1, 4, 25) + expect(items[0].textContent).toContain("bulbasaur"); + expect(items[1].textContent).toContain("charmander"); + expect(items[2].textContent).toContain("pikachu"); + }); + + test("calls onSelect with correct ID when clicked", () => { + const handleSelect = vi.fn(); + render(); + + const pikachuButton = screen.getByText("pikachu"); + fireEvent.click(pikachuButton); + + expect(handleSelect).toHaveBeenCalledWith(25); + }); + + test("matches snapshot", () => { + const { asFragment } = render( + {}} + selectedId={4} + />, + ); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/src/components/PokemonList/PokemonList.tsx b/src/components/PokemonList/PokemonList.tsx new file mode 100644 index 0000000..7de40f2 --- /dev/null +++ b/src/components/PokemonList/PokemonList.tsx @@ -0,0 +1,54 @@ +import "./PokemonList.css"; + +export type ListPokemon = { + id: number; + name: string; + sprite: string; +}; + +type PokemonListProps = { + pokemons: ListPokemon[]; + onSelect: (id: number) => void; + selectedId?: number; +}; + +export function PokemonList({ + pokemons, + onSelect, + selectedId, +}: PokemonListProps) { + // Ensure the list is always sorted by ID as required + const sortedPokemons = [...pokemons].sort((a, b) => a.id - b.id); + + if (sortedPokemons.length === 0) { + return ( +
No Pokémon available to display.
+ ); + } + + return ( +
+ {sortedPokemons.map((pokemon) => { + const isSelected = pokemon.id === selectedId; + return ( + + ); + })} +
+ ); +}