diff --git a/.cta.json b/.cta.json new file mode 100644 index 000000000..ae0fd144c --- /dev/null +++ b/.cta.json @@ -0,0 +1,14 @@ +{ + "projectName": "pokedex", + "mode": "file-router", + "typescript": true, + "packageManager": "npm", + "tailwind": true, + "addOnOptions": {}, + "git": true, + "version": 1, + "framework": "react-cra", + "chosenAddOns": [ + "biome" + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..f0dc3a7ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +count.txt +.env +.nitro +.tanstack +.wrangler +*-lock.json \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..b001961b5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,35 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[css]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "explicit" + } +} diff --git a/README.md b/README.md index 36ca6165d..573ff6c57 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,137 @@ -### Would you like to work with us? Apply [here](https://looqbox.gupy.io/)! - -# Looqbox FrontEnd Challenge -![Looqbox](https://github.com/looqbox/looqbox-frontend-challenge/blob/master/logo.png) - -## Challenge -In this challenge you will need to build a **S**ingle **P**age **A**pplication using ReactJS and a provided api - -We will not use anything from your project other than evaluate your skills and you are free to use it in your portfolio - -## Stack -We use: -- ReactJS -- Redux -- TypeScript -- AntDesign - -## Submitting -- Make a fork of this repository -- Create your branch -- ⚠️ Do a initial Commit when you start -- ⚠️ Do a final commit when you finish -- When you're done send us a pull request - -# Guidelines -You need to create a Single Page Application (SPA) that displays a list of Pokémon and allows users to search for them, using the [Pokeapi](https://pokeapi.co/docs/v2). Your app must be dynamic, meaning you **must not** reload the page to show new content. - -The PokeAPI was chosen for its simplicity in making requests. Since it is an open API, please **be mindful of how many requests** you make. - -## Requirements: - -- On the main page, include a search bar and a preloaded list of Pokémon. -- Clicking on any Pokémon should display a card, modal, or page with that Pokémon’s information. -- Typing in the search bar and pressing Enter should display the search result instead of the list. -- Your app must include at least two different routes (e.g., /home, /details — be creative!). -- Add a README file to document your project. - -You may use any libraries or dependencies you like (e.g., Axios, Bootstrap, Material UI...). - -## Bonus points! -- Pagination -- Error handling -- Documentation -- Linting -- Charts -- Unit Testing -- Ant Design - -## Useful links -- [React docs](https://react.dev/) -- [PokeApi docs](https://pokeapi.co/docs/v2) -- [Redux](https://redux.js.org/) +# Guia de bolso para projetos de software (Pokédex) + +Bem-vindo(a)! +Pokédex, lista de Pokémons... De novo? Bom, sim e não. É óbvio que este projeto não é sobre código complexo (nem deveria), grandes arquiteturas e novos padrões de desenvolvimento. Até porquê se fosse eu não teria feito uma Pokédex. Mas você já se perguntou quais elementos formam um bom projeto de software? + +Vou te perguntar de outra forma. Porquê quando você acessa esse tipo de projeto, tão simples, você ainda pode encontrar, com bastante frequência, um código nada intuitivo e fácil de manter? + +Eu posso chutar uma razão: porquê para que um projetos simples ou complexo, tanto faz, possa ser fácil de mudar e satisfatório para o(s) usuário(s), esse projeto **depende da preparação e simplicidade do idealizador e abordagem pragmática e simplicidade do desenvolvedor.** Nessa matemática, simplicidade conta em dobro. + +Este projeto tenta provar esse argumento e oferecer um guia de bolso para caso você se esqueça, absorto no código e reuniões diárias, do que realmente importa para projetos de software fluírem bem, junto com você. + +## Sumário + +* [Funcionalidades](#funcionalidades) +* [Decisões Técnicas](#decisões-técnicas---e-porquê) +* [Tecnologias usadas](#tecnologias-usadas) +* [Próximos Passos (Roadmap)](#próximos-passos-roadmap) +* [Como rodar e revisar o projeto](#como-rodar-e-revisar-o-projeto) + +--- + +## Funcionalidades + +Aqui está o resumo das funcionalidades implementadas. + +* **🔍 Busca:** Barra de busca funcional na página principal. Ao pressionar **Enter**, a lista é filtrada dinamicamente, priorizando a intenção do usuário. +* **📜 Listagem:** Lista de Pokémons pré-carregada para evitar telas vazias no primeiro acesso. +* **📑 Detalhes (incompleto):** Página dedicada para cada Pokémon, exibindo informações, estatísticas e gráficos. +* **🛣️ Roteamento Avançado:** Navegação multipáginas utilizando um sistema de rotas baseado em arquivos (`/home` e `/details/$pokemonName`). +* **♿ Acessibilidade (A11y):** Navegação completa via teclado. Use `Tab` para percorrer e `Enter` para selecionar. +* **🖼️ Fallback de Imagem:** Tratativa para Pokémons sem foto oficial com a clássica imagem do *"Quem é esse Pokémon?"*. +image + +### Bonus! + +Os seguintes itens extras que garantem a robustez do projeto: + +* [x] **Error Handling:** Sistema de captura de erros com feedback visual e contato direto com o dev. +* [x] **Documentation:** Este guia completo de onboarding. +* [x] **Linting:** Configuração rigorosa com Biome para código limpo. +* [ ] **Charts (incompleto):** Visualização de stats com Recharts. +* [x] **Unit Testing:** Lógica central testada com Vitest. + +## Decisões técnicas – e porquê + +Minha maior decisão técnica foi separar as responsabilidades da `PokemonsList`. Segui uma arquitetura **inspirada no MVC (Model-View-Controller)** adaptada ao ecossistema React. + +image + +### 1. A Camada de Serviço (Service Layer) +Isolei toda a comunicação externa e as regras de negócio em `src/services/pokedex.ts`. + +* **Por que?** Desacoplamento total. Se a PokéAPI mudar seus tipos, métodos etc ou se precisarmos trocar a biblioteca de fetch, a interface do usuário permanece intacta. + +### 2. Separação entre tratamento dos dados e renderização + +Deleguei o controle do fluxo de dados (loading, erro, filtragem e fetching) para um **hook personalizado integrado ao Context**. + +* **Por que?** A `PokemonsList` foca 100% na "View". Ela não sabe como os dados chegam; ela apenas os renderiza da melhor forma visual. Isso torna o código altamente testável e fácil de manter. + +### 3. UX na busca por Pokémon + +* **Tratativa de 404:** Em vez de exibir um erro sistêmico, trato o "Not Found" como um estado da interface, informando amigavelmente que nenhum Pokémon foi encontrado. +* **Feedback Proativo:** Implementei uma forma de contato direto comigo que envia o erro automaticamente. +image + + +## Tecnologias usadas + +> **Atenção:** Toda decisão técnica serve apenas para este snapshot do projeto. Não me preocupo em mudar de ideia, caso os requisitos e contexto mudem. A melhor decisão é a que resolve o problema do negócio com a ferramenta que traz menos colaterais indesejáveis. + +| Tecnologia | Por que usei? | +| --- | --- | +| **Vite** | Aplicação simples, poucas rotas e sem necessidade de SEO pesado. Custo de deploy menor (Static) sem precisar de servidor Node.js para SSR. | +| **TanStack Router** | Roteamento robusto com **Type Safety** absoluto e excelente suporte para *search params* (rota `/details`). | +| **TailwindCSS** | Optei por não usar libs de componentes (como AntDesign) para manter o bundle leve e ter controle total do design (algo que valorizo muito) sem "sobrescrever" estilos pesados. Cuidei para que a interface tivessse boa acessibilidade, ainda que dessa forma mais "crua". | +| **Biome** | Toolchain *all-in-one* para lint e format. Mais rápido que Prettier e com quase zero configuração. | +| **Vitest** | Rapidez e ótima DX para garantir que o "coração" da lógica esteja seguro, com bons testes automatizados. | +| **pokenode-ts** | Recomendação oficial da PokéAPI. Tipagem *built-in* que facilita a integração e paginação, além de função de cache configurado com Axios. | + +#### Detalhe para os tipos - arquivos `.d.ts` + +Utilizei arquivos de definição de tipos específicos para não "sujar" o código de produção com tipos que não geram código executável. + +## Próximos Passos (Roadmap) + +Se eu tivesse mais tempo (ou para uma V2), meu foco seria: + +* **GraphQL:** Implementar para evitar *over-fetching* na home, buscando e organizando apenas os dados estritamente necessários para a listagem inicial. +* **Paginação:** Dessa forma a página inicial pode mostrar mais Pokémons e o usuário navegar na vasta coleção que existe. O módulo `pokenode-ts` facilita esse processo ainda mais. +* **Busca por termo incompleto (fuzzy search):** Eu percebi que a PokéAPI não permite pesquisar dessa forma nativamente, como um _fuzzy search_. A aplicação hoje busca por termos exatos, mas seria muito legal implementar isso de alguma forma no futuro. As soluções que pensei eram desnecessariamente complexas... + +## Como rodar e revisar o projeto: + +1. Clone o repositório: + + ```bash + git clone https://github.com/allbertuu/pokedex.git + ``` + +2. Acesse o diretório do projeto: + + ```bash + cd pokedex + ``` + +3. Instale as dependências: + + ```bash + npm install + ``` + +4. Inicie o servidor de desenvolvimento: + + ```bash + npm run dev + ``` + +5. Abra o navegador e acesse: + + ```bash + http://localhost:3000 + ``` + +## Testes + +Para executar os testes, utilize o comando: + +```bash +npm test +``` + +**Recomendação de Revisão:** Comece pelo arquivo `src/services/pokedex.ts` (a base), passe pelo `src/contexts/PokemonContext.tsx` (a orquestração) e termine nos componentes da pasta `pages/`. + +--- + +O código limpo, simples e bem planejado é, acima de tudo, um gesto de respeito aos colegas de equipe e usuários do sistema. \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..f54c1a0a7 --- /dev/null +++ b/biome.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": [ + "**/src/**/*", + "**/.vscode/**/*", + "**/index.html", + "**/vite.config.ts", + "!**/src/routeTree.gen.ts", + "!**/src/styles.css" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/index.html b/index.html new file mode 100644 index 000000000..51408876c --- /dev/null +++ b/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + Pokédex + + + +
+ + + diff --git a/logo.png b/logo.png deleted file mode 100644 index 309a466d1..000000000 Binary files a/logo.png and /dev/null differ diff --git a/package.json b/package.json new file mode 100644 index 000000000..30939f4a2 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "pokedex", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "build": "vite build && tsc", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest run --watch", + "test:ui": "vitest --ui", + "format": "biome format", + "lint": "biome lint", + "check": "biome check", + "check:write": "biome check --write" + }, + "dependencies": { + "@tailwindcss/vite": "^4.0.6", + "@tanstack/react-devtools": "^0.7.0", + "@tanstack/react-router": "^1.132.0", + "@tanstack/react-router-devtools": "^1.132.0", + "@tanstack/router-plugin": "^1.132.0", + "axios": "^1.13.5", + "axios-cache-interceptor": "^1.11.4", + "lucide-react": "^0.545.0", + "motion": "^12.34.0", + "pokenode-ts": "^1.20.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "recharts": "^3.7.0", + "tailwindcss": "^4.0.6" + }, + "devDependencies": { + "@biomejs/biome": "2.2.4", + "@tanstack/devtools-vite": "^0.3.11", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.10.2", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "jsdom": "^27.4.0", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vitest": "^3.2.4", + "web-vitals": "^5.1.0" + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 000000000..a11777cc4 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/logo192.png b/public/logo192.png new file mode 100644 index 000000000..fc44b0a37 Binary files /dev/null and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png new file mode 100644 index 000000000..a4e47a654 Binary files /dev/null and b/public/logo512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 000000000..078ef5011 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "TanStack App", + "name": "Create TanStack App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/public/tanstack-circle-logo.png b/public/tanstack-circle-logo.png new file mode 100644 index 000000000..9db3e67ba Binary files /dev/null and b/public/tanstack-circle-logo.png differ diff --git a/public/tanstack-word-logo-white.svg b/public/tanstack-word-logo-white.svg new file mode 100644 index 000000000..b6ec5086c --- /dev/null +++ b/public/tanstack-word-logo-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/PokemonsList/PokemonsList.test.tsx b/src/components/PokemonsList/PokemonsList.test.tsx new file mode 100644 index 000000000..d81d7aca3 --- /dev/null +++ b/src/components/PokemonsList/PokemonsList.test.tsx @@ -0,0 +1,174 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { usePokemonsList } from "@/hooks/usePokemonsList"; +import { PokemonsList, prettifyPokemonId } from "./PokemonsList"; + +// all functions exported there are mocked here +vi.mock("../../hooks/usePokemonsList"); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: vi.fn(() => vi.fn()), +})); + +const pokemonsListMock = [ + { + id: 25, + name: "pikachu", + types: ["eletric"], + image: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png", + }, + { + id: 1, + name: "bulbasaur", + types: ["grass", "poison"], + image: + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png", + }, +]; + +const usePokemonsListMockWithError = { + pokemons: [], + loading: false, + error: "Sou uma mensagem de erro", + filterPokemonsByPokemonName: () => Promise.resolve(), +}; + +const usePokemonsListMockWithLoading = { + pokemons: [], + loading: true, + error: null, + filterPokemonsByPokemonName: () => Promise.resolve(), +}; + +const usePokemonsListMockPopulated = { + pokemons: pokemonsListMock, + loading: false, + error: null, + filterPokemonsByPokemonName: () => Promise.resolve(), +}; + +describe("PokemonsList", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should show loading state at first", () => { + vi.mocked(usePokemonsList).mockImplementation( + () => usePokemonsListMockWithLoading, + ); + + render(); + + const loadingMessageElement = screen.getByText(/aguarde.../i); + + expect(loadingMessageElement).toBeInTheDocument(); + }); + + it("should show an error message when any errors occurs", () => { + vi.mocked(usePokemonsList).mockImplementation( + () => usePokemonsListMockWithError, + ); + + render(); + + const errorMessageElement = screen.getByText( + usePokemonsListMockWithError.error, + ); + + expect(errorMessageElement).toBeInTheDocument(); + }); + + it("should show a list of preloaded pokemons", async () => { + vi.mocked(usePokemonsList).mockImplementation( + () => usePokemonsListMockPopulated, + ); + + render(); + + const pokemonCardsElements = await screen.findAllByTestId("pokemonCard"); + + // accessibility + expect(pokemonCardsElements[0]).toHaveAttribute("tabIndex", "0"); + expect(pokemonCardsElements[1]).toHaveAttribute("tabIndex", "0"); + }); + + it("should show correct pokemon information", async () => { + vi.mocked(usePokemonsList).mockImplementation( + () => usePokemonsListMockPopulated, + ); + + const firstPokemonMock = pokemonsListMock[0]; + const secondPokemonMock = pokemonsListMock[1]; + + render(); + + const firstPokemonNameElement = await screen.findByText( + firstPokemonMock.name, + ); + const firstPokemonIdElement = await screen.findByText( + prettifyPokemonId(firstPokemonMock.id), + ); + const firstPokemonTypeElement = await screen.findByText( + firstPokemonMock.types[0], + ); + const firstPokemonImageElement = await screen.findByAltText( + `Front view of the Pokémon ${firstPokemonMock.name}`, + ); + + const secondPokemonNameElement = await screen.findByText( + secondPokemonMock.name, + ); + const secondPokemonIdElement = await screen.findByText( + prettifyPokemonId(secondPokemonMock.id), + ); + const secondPokemonTypeOneElement = await screen.findByText( + secondPokemonMock.types[0], + ); + const secondPokemonTypeTwoElement = await screen.findByText( + secondPokemonMock.types[1], + ); + const secondPokemonImageElement = await screen.findByAltText( + `Front view of the Pokémon ${secondPokemonMock.name}`, + ); + + expect(firstPokemonNameElement).toBeVisible(); + expect(firstPokemonIdElement).toBeVisible(); + expect(firstPokemonTypeElement).toBeVisible(); + expect(firstPokemonImageElement).toBeVisible(); + expect(secondPokemonNameElement).toBeVisible(); + expect(secondPokemonIdElement).toBeVisible(); + expect(secondPokemonTypeOneElement).toBeVisible(); + expect(secondPokemonTypeTwoElement).toBeVisible(); + expect(secondPokemonImageElement).toBeVisible(); + }); + + it.todo( + "should open pokemon details page when selected one by the list", + async () => { + const user = userEvent.setup(); // setup simulated user + const firstPokemon = pokemonsListMock[0]; + console.log(window.location.href); + vi.mocked(usePokemonsList).mockImplementation( + () => usePokemonsListMockPopulated, + ); + + // const useNavigateMock = vi + // .mocked(useNavigate) + // .mockImplementation(vi.fn()); + // useNavigateMock.mockImplementation(() => { + // window.location.href = `${window.location.hostname}/details/${firstPokemon.name}`; + // return vi.fn(); + // }); + + render(); + + const firstPokemonElement = await screen.findByText("firstPokemon.name"); + + await user.click(firstPokemonElement); + + // get the URL and verifies if it's on details first Pokemon's specific page + // expect(window.location.href).toContain(firstPokemon.name); + // expect(firstPokemonElement).toBeInTheDocument(); + }, + ); +}); diff --git a/src/components/PokemonsList/PokemonsList.tsx b/src/components/PokemonsList/PokemonsList.tsx new file mode 100644 index 000000000..b259371ec --- /dev/null +++ b/src/components/PokemonsList/PokemonsList.tsx @@ -0,0 +1,148 @@ +import { useNavigate } from "@tanstack/react-router"; +import { CircleXIcon, FrownIcon, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; +import { usePokemonsList } from "@/hooks/usePokemonsList"; +import noPokemonImage from "../../images/no-pokemon.jpg"; + +const POKEMON_TYPE_COLORS: Record = { + fire: "#F08030", + water: "#6890F0", + grass: "#78C850", + electric: "#F8D030", + ice: "#98D8D8", + fighting: "#C03028", + poison: "#A040A0", + ground: "#E0C068", + flying: "#A890F0", + psychic: "#F85888", + bug: "#A8B820", + rock: "#B8A038", + ghost: "#705898", + dragon: "#7038F8", + dark: "#705848", + steel: "#B8B8D0", + fairy: "#EE99AC", + normal: "#A8A878", +}; + +/** + * Return id (number) in format "#025" + */ +export function prettifyPokemonId(id: number) { + return `#${id.toString().padStart(3, "0")}`; +} + +export const PokemonsList = () => { + const { pokemons, error, loading } = usePokemonsList(); + const navigate = useNavigate(); + + function goToPokemonDetailsPage(pokemonName: string) { + navigate({ + to: "/details/$pokemonName", + params: { pokemonName }, + }); + } + + if (loading) { + return ( +
+ +

Procurando Pokémons! Aguarde...

+
+ ); + } + + if (error) { + return ( +
+ +

+ Houve um erro ao buscar os Pokémons. +
Mensagem de erro: {error} +

+
+

+ Opa, sortudo(a)! Você pegou um erro. Que tal{" "} + + me avisar + + ? +

+

Vai me ajudar muito a corrigi-lo :D

+
+ ); + } + + return ( +
+ {pokemons.length > 0 ? ( + pokemons.map((pokemon, idx) => ( + goToPokemonDetailsPage(pokemon.name)} + onKeyPress={() => goToPokemonDetailsPage(pokemon.name)} + > +
+
+ {`Front + + {prettifyPokemonId(pokemon.id)} + +
+
+

+ {pokemon.name} +

+
+ {pokemon.types.map((type) => ( + + {type} + + ))} +
+
+ + )) + ) : ( +
+ +

+ Nenhum Pokémon com esse nome foi encontrado. +

+
+ )} +
+ ); +}; diff --git a/src/components/SearchPokemonByNameOrId/SearchPokemonByNameOrId.tsx b/src/components/SearchPokemonByNameOrId/SearchPokemonByNameOrId.tsx new file mode 100644 index 000000000..471d55f86 --- /dev/null +++ b/src/components/SearchPokemonByNameOrId/SearchPokemonByNameOrId.tsx @@ -0,0 +1,44 @@ +import { InfoIcon, Search } from "lucide-react"; +import { type ChangeEvent, type KeyboardEvent, useState } from "react"; +import { usePokemonsList } from "@/hooks/usePokemonsList"; + +export const SearchPokemonByNameOrId = () => { + const [searchTerm, setSearchTerm] = useState(""); + const { filterPokemonsByPokemonName } = usePokemonsList(); + + function handleInputOnChange(e: ChangeEvent) { + setSearchTerm(e.target.value); + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") { + filterPokemonsByPokemonName(searchTerm); + // cleans input search after click Enter + setSearchTerm(""); + } + } + + return ( +
+
+ + + +
+ +
+ +

+ Se buscar vazio, retorna alguns Pokémons +

+
+
+ ); +}; diff --git a/src/contexts/PokemonsList.tsx b/src/contexts/PokemonsList.tsx new file mode 100644 index 000000000..498134313 --- /dev/null +++ b/src/contexts/PokemonsList.tsx @@ -0,0 +1,77 @@ +import { createContext, useCallback, useEffect, useState } from "react"; +import type { Pokemon } from "@/types/pokedex"; +import { getAllPokemons, getPokemonByName } from "../services/pokedex"; + +export const PokemonsListContext = createContext( + {} as { + pokemons: Pokemon[]; + loading: boolean; + error: string | null; + filterPokemonsByPokemonName: (name: string) => Promise; + }, +); + +export const PokemonsListProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [pokemons, setPokemons] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function filterPokemonsByPokemonName(name: string) { + // if none pokemon is searched, list initial + if (name === "") { + await fetchInitialPokemons(); + return; + } + + setLoading(true); + + try { + const pokemon = await getPokemonByName(name); + + setPokemons([pokemon]); + } catch (err) { + if (err instanceof Error) { + if (err.message.includes("404")) { + setPokemons([]); + return; + } + } + setError(String(err)); + } finally { + setLoading(false); + } + } + + const fetchInitialPokemons = useCallback(async () => { + setLoading(true); + const pokemonsQuantity = 10; + + try { + const data = await getAllPokemons(pokemonsQuantity); + setPokemons(data); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } + setError(String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchInitialPokemons(); + }, [fetchInitialPokemons]); + + return ( + + {children} + + ); +}; diff --git a/src/hooks/usePokemonsList.ts b/src/hooks/usePokemonsList.ts new file mode 100644 index 000000000..018a40a29 --- /dev/null +++ b/src/hooks/usePokemonsList.ts @@ -0,0 +1,4 @@ +import { useContext } from "react"; +import { PokemonsListContext } from "@/contexts/PokemonsList"; + +export const usePokemonsList = () => useContext(PokemonsListContext); diff --git a/src/images/no-pokemon.jpg b/src/images/no-pokemon.jpg new file mode 100644 index 000000000..d5148cc00 Binary files /dev/null and b/src/images/no-pokemon.jpg differ diff --git a/src/logo.svg b/src/logo.svg new file mode 100644 index 000000000..fe53fe8d0 --- /dev/null +++ b/src/logo.svg @@ -0,0 +1,12 @@ + + + logo + + \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 000000000..e03cee8b6 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,42 @@ +import { createRouter, RouterProvider } from "@tanstack/react-router"; +import { StrictMode } from "react"; +import ReactDOM from "react-dom/client"; + +// Import the generated route tree +import { routeTree } from "./routeTree.gen"; + +import "./styles.css"; +import reportWebVitals from "./reportWebVitals.ts"; + +// Create a new router instance +const router = createRouter({ + routeTree, + context: {}, + defaultPreload: "intent", + scrollRestoration: true, + defaultStructuralSharing: true, + defaultPreloadStaleTime: 0, +}); + +// Register the router instance for type safety +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +// Render the app +const rootElement = document.getElementById("app"); +if (rootElement && !rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement); + root.render( + + + , + ); +} + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts new file mode 100644 index 000000000..cb97c7fdb --- /dev/null +++ b/src/reportWebVitals.ts @@ -0,0 +1,13 @@ +const reportWebVitals = (onPerfEntry?: () => void) => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import("web-vitals").then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => { + onCLS(onPerfEntry); + onINP(onPerfEntry); + onFCP(onPerfEntry); + onLCP(onPerfEntry); + onTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts new file mode 100644 index 000000000..162b62b68 --- /dev/null +++ b/src/routeTree.gen.ts @@ -0,0 +1,113 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as HomeRouteImport } from './routes/home' +import { Route as IndexRouteImport } from './routes/index' +import { Route as DetailsIndexRouteImport } from './routes/details/index' +import { Route as DetailsPokemonNameRouteImport } from './routes/details/$pokemonName' + +const HomeRoute = HomeRouteImport.update({ + id: '/home', + path: '/home', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const DetailsIndexRoute = DetailsIndexRouteImport.update({ + id: '/details/', + path: '/details/', + getParentRoute: () => rootRouteImport, +} as any) +const DetailsPokemonNameRoute = DetailsPokemonNameRouteImport.update({ + id: '/details/$pokemonName', + path: '/details/$pokemonName', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/home': typeof HomeRoute + '/details/$pokemonName': typeof DetailsPokemonNameRoute + '/details/': typeof DetailsIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/home': typeof HomeRoute + '/details/$pokemonName': typeof DetailsPokemonNameRoute + '/details': typeof DetailsIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/home': typeof HomeRoute + '/details/$pokemonName': typeof DetailsPokemonNameRoute + '/details/': typeof DetailsIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/home' | '/details/$pokemonName' | '/details/' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/home' | '/details/$pokemonName' | '/details' + id: '__root__' | '/' | '/home' | '/details/$pokemonName' | '/details/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + HomeRoute: typeof HomeRoute + DetailsPokemonNameRoute: typeof DetailsPokemonNameRoute + DetailsIndexRoute: typeof DetailsIndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/home': { + id: '/home' + path: '/home' + fullPath: '/home' + preLoaderRoute: typeof HomeRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/details/': { + id: '/details/' + path: '/details' + fullPath: '/details/' + preLoaderRoute: typeof DetailsIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/details/$pokemonName': { + id: '/details/$pokemonName' + path: '/details/$pokemonName' + fullPath: '/details/$pokemonName' + preLoaderRoute: typeof DetailsPokemonNameRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + HomeRoute: HomeRoute, + DetailsPokemonNameRoute: DetailsPokemonNameRoute, + DetailsIndexRoute: DetailsIndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx new file mode 100644 index 000000000..de1182e34 --- /dev/null +++ b/src/routes/__root.tsx @@ -0,0 +1,26 @@ +import { TanStackDevtools } from "@tanstack/react-devtools"; +import { createRootRoute, Outlet } from "@tanstack/react-router"; +import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; +import { PokemonsListProvider } from "@/contexts/PokemonsList"; + +export const Route = createRootRoute({ + component: () => ( + <> + + + + + , + }, + ]} + /> + + ), +}); diff --git a/src/routes/details/$pokemonName.tsx b/src/routes/details/$pokemonName.tsx new file mode 100644 index 000000000..ae5d9665c --- /dev/null +++ b/src/routes/details/$pokemonName.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/details/$pokemonName")({ + component: PokemonDetailsByNamePage, +}); + +function PokemonDetailsByNamePage() { + const { pokemonName } = Route.useParams(); + + return
Hello {pokemonName}!
; +} diff --git a/src/routes/details/index.tsx b/src/routes/details/index.tsx new file mode 100644 index 000000000..a94727a45 --- /dev/null +++ b/src/routes/details/index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/details/")({ + component: PokemonDetailsPage, + beforeLoad: () => { + throw Route.redirect({ + to: "/home", + }); + }, +}); + +function PokemonDetailsPage() { + return
Hello "/details/"!
; +} diff --git a/src/routes/home.tsx b/src/routes/home.tsx new file mode 100644 index 000000000..f630b77fd --- /dev/null +++ b/src/routes/home.tsx @@ -0,0 +1,41 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { motion } from "motion/react"; +import { PokemonsList } from "@/components/PokemonsList/PokemonsList"; +import { SearchPokemonByNameOrId } from "@/components/SearchPokemonByNameOrId/SearchPokemonByNameOrId"; + +export const Route = createFileRoute("/home")({ + component: HomePage, +}); + +function HomePage() { + return ( + +
+
+
+
+
+ +

+ POKÉDEX +

+
+ + +
+ + +
+ ); +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx new file mode 100644 index 000000000..a1368fd90 --- /dev/null +++ b/src/routes/index.tsx @@ -0,0 +1,81 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { HeartIcon, PlayCircle } from "lucide-react"; +import { motion } from "motion/react"; + +export const Route = createFileRoute("/")({ + component: WelcomePage, +}); + +function WelcomePage() { + return ( + + + + + + + Pressione Iniciar + + + +
+

+ FEITO COM{" "} + {" "} + POR{" "} + + Alberto Santos + +

+
+
+ ); +} diff --git a/src/services/pokedex.ts b/src/services/pokedex.ts new file mode 100644 index 000000000..bb35ede72 --- /dev/null +++ b/src/services/pokedex.ts @@ -0,0 +1,46 @@ +import { AxiosError } from "axios"; +import { PokemonClient } from "pokenode-ts"; +import type { Pokemon } from "@/types/pokedex"; + +// enables logs +// it comes with Cache (axios) by default +const api = new PokemonClient({ logs: true }); + +export async function getAllPokemons(limit: number = 10) { + const { results = [] } = await api.listPokemons(undefined, limit); + + const pokemons: Pokemon[] = []; + + for (const { name } of results) { + const pokemon = await api.getPokemonByName(name); + + pokemons.push({ + id: pokemon.id, + name, + types: pokemon.types.map(({ type }) => type.name), + image: pokemon.sprites.front_default, + }); + } + + return pokemons; +} + +export async function getPokemonByName(name: string) { + try { + const pokemonResult = await api.getPokemonByName(name); + const pokemon = { + id: pokemonResult.id, + name: pokemonResult.name, + types: pokemonResult.types.map(({ type }) => type.name), + image: pokemonResult.sprites.front_default, + }; + + return pokemon; + } catch (error) { + if (error instanceof AxiosError) { + throw Error(error.message); + } + + throw Error(String(error)); + } +} diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 000000000..d0de870dc --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 000000000..8aae5dd5c --- /dev/null +++ b/src/styles.css @@ -0,0 +1,20 @@ +@import "tailwindcss"; + +body { + @apply m-0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #0A0A0A; +} + +button { + cursor: pointer; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", + monospace; +} diff --git a/src/types/pokedex.d.ts b/src/types/pokedex.d.ts new file mode 100644 index 000000000..64646e0d0 --- /dev/null +++ b/src/types/pokedex.d.ts @@ -0,0 +1,6 @@ +export type Pokemon = { + id: number; + name: string; + types: string[]; + image: string | null; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..1e58922d7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 000000000..4be12ff0a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,31 @@ +/// +import { fileURLToPath, URL } from "node:url"; +import tailwindcss from "@tailwindcss/vite"; +import { devtools } from "@tanstack/devtools-vite"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + devtools(), + tanstackRouter({ + target: "react", + autoCodeSplitting: true, + }), + viteReact(), + tailwindcss(), + ], + test: { + globals: true, // Permite usar describe, it, expect sem importar + environment: "jsdom", // Simula o navegador + setupFiles: "./src/setupTests.ts", // Arquivo de configuração que criaremos + css: true, // Útil se você testa estilos/classes + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, +});