mirror of
https://github.com/CarGDev/debug-dojo.git
synced 2025-09-18 09:58:37 +00:00
feat: add autocomplete search with Zustand store and API layer
- Add PokemonAutoComplete atom component using Ant Design - Implement API layer with axios for Pokémon data fetching - Create Zustand store for centralized state management - Add autocomplete suggestions with real-time filtering - Update SearchBar molecule to use autocomplete functionality - Load all Pokémon names on app initialization - Add path aliases for cleaner imports (@api, @stores) - Remove old service layer in favor of API layer - Maintain intentional bugs for debugging practice - Update project structure and documentation
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
112
README.md
Normal file
112
README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Pokémon Explorer App
|
||||
|
||||
A Vite + React + TypeScript application that allows users to search for Pokémon and view their details using the PokeAPI.
|
||||
|
||||
## Features
|
||||
|
||||
- Search for Pokémon by name with autocomplete suggestions
|
||||
- Display Pokémon details including:
|
||||
- Name and image
|
||||
- Types
|
||||
- Base stats with progress bars
|
||||
- Height and weight
|
||||
- Modern UI built with Ant Design
|
||||
- Atomic Design architecture
|
||||
- Centralized state management with Zustand
|
||||
- API layer with axios for data fetching
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API layer with axios
|
||||
│ ├── config.ts # Axios configuration
|
||||
│ ├── get/ # GET requests
|
||||
│ ├── post/ # POST requests
|
||||
│ ├── put/ # PUT requests
|
||||
│ ├── delete/ # DELETE requests
|
||||
│ └── index.ts # API exports
|
||||
├── components/
|
||||
│ ├── atoms/ # Basic UI elements (Input, Button, AutoComplete)
|
||||
│ ├── molecules/ # Combinations (SearchBar)
|
||||
│ ├── organisms/ # Complex components (PokemonDetails)
|
||||
│ ├── templates/ # Layout structure (MainLayout)
|
||||
│ └── pages/ # Route-level views (Home)
|
||||
├── stores/ # Zustand state management
|
||||
│ ├── pokemonStore.ts # Pokémon state store
|
||||
│ └── index.ts # Store exports
|
||||
├── styles/ # SCSS modules
|
||||
│ ├── global.module.scss # Global styles
|
||||
│ ├── app.module.scss # App-specific styles
|
||||
│ └── index.ts # Style exports
|
||||
└── types/ # TypeScript type definitions
|
||||
```
|
||||
|
||||
## Path Aliases
|
||||
|
||||
The project uses path aliases for cleaner imports:
|
||||
|
||||
- `@/` - Points to `src/`
|
||||
- `@components/` - Points to `src/components/`
|
||||
- `@styles/` - Points to `src/styles/`
|
||||
- `@pokemonTypes/` - Points to `src/types/`
|
||||
- `@api/` - Points to `src/api/`
|
||||
- `@stores/` - Points to `src/stores/`
|
||||
|
||||
Example usage:
|
||||
```typescript
|
||||
// Instead of: import { PokemonInput } from '../../components/atoms/PokemonInput';
|
||||
import { PokemonInput } from '@components/atoms/PokemonInput';
|
||||
|
||||
// Instead of: import styles from '../../styles/app.module.scss';
|
||||
import styles from '@styles/app.module.scss';
|
||||
|
||||
// Instead of: import { getPokemon } from '../../api/get/pokemon';
|
||||
import { getPokemon } from '@api/get/pokemon';
|
||||
|
||||
// Instead of: import { usePokemonStore } from '../../stores/pokemonStore';
|
||||
import { usePokemonStore } from '@stores/pokemonStore';
|
||||
```
|
||||
|
||||
## Intentional Bugs (For Debugging Session)
|
||||
|
||||
⚠️ **These bugs are intentionally left in the code for debugging practice:**
|
||||
|
||||
1. **Silent Failure**: When a Pokémon doesn't exist, no error message is shown - the app silently fails
|
||||
2. **UI Flickering**: When searching for a new Pokémon, the previous data briefly disappears causing a flicker effect
|
||||
3. **Incorrect Stat Mapping**: Speed and defense stats are swapped in the display
|
||||
4. **TypeScript Error**: The `height` field is assumed to always be present when it's actually optional in the API
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Open your browser and navigate to the URL shown in the terminal
|
||||
|
||||
## Usage
|
||||
|
||||
1. Enter a Pokémon name in the search bar (e.g., "pikachu", "charizard", "bulbasaur")
|
||||
2. Click "Search" or press Enter
|
||||
3. View the Pokémon's details including stats, types, and image
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **Vite** - Build tool and dev server
|
||||
- **React** - UI framework
|
||||
- **TypeScript** - Type safety
|
||||
- **Ant Design** - UI component library
|
||||
- **PokeAPI** - Pokémon data source
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
5078
package-lock.json
generated
Normal file
5078
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "pokemon-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^24.1.0",
|
||||
"antd": "^5.26.7",
|
||||
"axios": "^1.11.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"sass": "^1.89.2",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
24
src/App.tsx
Normal file
24
src/App.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { MainLayout } from '@components/templates/MainLayout';
|
||||
import { Home } from '@components/pages/Home';
|
||||
import styles from '@styles/app.module.scss';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#1890ff',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className={styles.app}>
|
||||
<MainLayout>
|
||||
<Home />
|
||||
</MainLayout>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
37
src/api/config.ts
Normal file
37
src/api/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = 'https://pokeapi.co/api/v2';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`Making ${config.method?.toUpperCase()} request to: ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('Request error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log(`Response received from: ${response.config.url}`, response.status);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('Response error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
34
src/api/get/pokemon.ts
Normal file
34
src/api/get/pokemon.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import api from '../config';
|
||||
import type { Pokemon } from '@pokemonTypes/pokemon';
|
||||
|
||||
export const getPokemon = async (name: string): Promise<Pokemon> => {
|
||||
try {
|
||||
const response = await api.get<Pokemon>(`/pokemon/${name.toLowerCase()}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Intentional bug #1: No proper error handling for non-existent Pokémon
|
||||
console.error('Error fetching Pokémon:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPokemonList = async (limit: number = 20, offset: number = 0) => {
|
||||
try {
|
||||
const response = await api.get(`/pokemon?limit=${limit}&offset=${offset}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching Pokémon list:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllPokemonNames = async (): Promise<string[]> => {
|
||||
try {
|
||||
// Fetch all Pokémon names (151 total in the original Pokédex)
|
||||
const response = await api.get('/pokemon?limit=151&offset=0');
|
||||
return response.data.results.map((pokemon: { name: string }) => pokemon.name);
|
||||
} catch (error) {
|
||||
console.error('Error fetching all Pokémon names:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
14
src/api/index.ts
Normal file
14
src/api/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// API configuration
|
||||
export { default as api } from './config';
|
||||
|
||||
// GET requests
|
||||
export * from './get/pokemon';
|
||||
|
||||
// POST requests (for future use)
|
||||
// export * from './post/';
|
||||
|
||||
// PUT requests (for future use)
|
||||
// export * from './put/';
|
||||
|
||||
// DELETE requests (for future use)
|
||||
// export * from './delete/';
|
54
src/components/atoms/PokemonAutoComplete.tsx
Normal file
54
src/components/atoms/PokemonAutoComplete.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { AutoComplete, Input } from 'antd';
|
||||
import type { AutoCompleteProps } from 'antd';
|
||||
|
||||
interface PokemonAutoCompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSearch: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
loading?: boolean;
|
||||
placeholder?: string;
|
||||
onSelect?: (value: string) => void;
|
||||
}
|
||||
|
||||
export const PokemonAutoComplete: React.FC<PokemonAutoCompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSearch,
|
||||
options,
|
||||
loading = false,
|
||||
placeholder = 'Enter Pokémon name...',
|
||||
onSelect,
|
||||
}) => {
|
||||
const handleSearch = (searchText: string) => {
|
||||
onChange(searchText);
|
||||
onSearch(searchText);
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue);
|
||||
onSelect?.(selectedValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
value={value}
|
||||
options={options}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
style={{ width: '100%' }}
|
||||
notFoundContent={loading ? 'Loading...' : 'No Pokémon found'}
|
||||
filterOption={false} // Disable built-in filtering since we handle it in the store
|
||||
>
|
||||
<Input.Search
|
||||
size="large"
|
||||
allowClear
|
||||
loading={loading}
|
||||
enterButton
|
||||
/>
|
||||
</AutoComplete>
|
||||
);
|
||||
};
|
30
src/components/atoms/PokemonButton.tsx
Normal file
30
src/components/atoms/PokemonButton.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
|
||||
interface PokemonButtonProps {
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
type?: 'primary' | 'default' | 'dashed' | 'link' | 'text';
|
||||
}
|
||||
|
||||
export const PokemonButton: React.FC<PokemonButtonProps> = ({
|
||||
onClick,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
children,
|
||||
type = 'primary'
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
size="large"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
24
src/components/atoms/PokemonInput.tsx
Normal file
24
src/components/atoms/PokemonInput.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Input } from 'antd';
|
||||
|
||||
interface PokemonInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const PokemonInput: React.FC<PokemonInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Enter Pokémon name...'
|
||||
}) => {
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
size="large"
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
};
|
16
src/components/index.ts
Normal file
16
src/components/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Atoms
|
||||
export { PokemonInput } from './atoms/PokemonInput';
|
||||
export { PokemonButton } from './atoms/PokemonButton';
|
||||
export { PokemonAutoComplete } from './atoms/PokemonAutoComplete';
|
||||
|
||||
// Molecules
|
||||
export { SearchBar } from './molecules/SearchBar';
|
||||
|
||||
// Organisms
|
||||
export { PokemonDetails } from './organisms/PokemonDetails';
|
||||
|
||||
// Templates
|
||||
export { MainLayout } from './templates/MainLayout';
|
||||
|
||||
// Pages
|
||||
export { Home } from './pages/Home';
|
32
src/components/molecules/SearchBar.tsx
Normal file
32
src/components/molecules/SearchBar.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { PokemonAutoComplete } from '@components/atoms/PokemonAutoComplete';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSearch: (value: string) => void;
|
||||
onSelect?: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const SearchBar: React.FC<SearchBarProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSearch,
|
||||
onSelect,
|
||||
options,
|
||||
loading = false
|
||||
}) => {
|
||||
return (
|
||||
<PokemonAutoComplete
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSearch={onSearch}
|
||||
onSelect={onSelect}
|
||||
options={options}
|
||||
loading={loading}
|
||||
placeholder="Enter Pokémon name..."
|
||||
/>
|
||||
);
|
||||
};
|
95
src/components/organisms/PokemonDetails.tsx
Normal file
95
src/components/organisms/PokemonDetails.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Tag, Progress, Typography, Image } from 'antd';
|
||||
import type { Pokemon } from '@pokemonTypes/pokemon';
|
||||
import styles from '@styles/app.module.scss';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface PokemonDetailsProps {
|
||||
pokemon: Pokemon | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const PokemonDetails: React.FC<PokemonDetailsProps> = ({
|
||||
pokemon,
|
||||
loading = false
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card loading={true} className={styles.pokemonCard}>
|
||||
<div style={{ height: 400 }} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pokemon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Intentional bug #3: Incorrect stat mapping
|
||||
// Speed and defense are swapped in the display
|
||||
const getStatName = (statName: string): string => {
|
||||
switch (statName) {
|
||||
case 'speed':
|
||||
return 'defense'; // Bug: showing defense instead of speed
|
||||
case 'defense':
|
||||
return 'speed'; // Bug: showing speed instead of defense
|
||||
default:
|
||||
return statName;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={styles.pokemonCard}>
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={8}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={pokemon.sprites.front_default}
|
||||
alt={pokemon.name}
|
||||
width={200}
|
||||
height={200}
|
||||
className={styles.pokemonImage}
|
||||
/>
|
||||
<Title level={2} className={styles.pokemonName}>
|
||||
{pokemon.name}
|
||||
</Title>
|
||||
<div className={styles.pokemonTypes}>
|
||||
{pokemon.types.map((type) => (
|
||||
<Tag key={type.type.name} color="blue" className={styles.typeTag}>
|
||||
{type.type.name.toUpperCase()}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<Text type="secondary">
|
||||
Height: {pokemon.height / 10}m | Weight: {pokemon.weight / 10}kg
|
||||
</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={16}>
|
||||
<Title level={3}>Base Stats</Title>
|
||||
<div className={styles.pokemonStats}>
|
||||
{pokemon.stats.map((stat) => (
|
||||
<div key={stat.stat.name} className={styles.statItem}>
|
||||
<div className={styles.statHeader}>
|
||||
<Text strong className={styles.statName}>
|
||||
{getStatName(stat.stat.name)}:
|
||||
</Text>
|
||||
<Text>{stat.base_stat}</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={(stat.base_stat / 255) * 100}
|
||||
showInfo={false}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
};
|
88
src/components/pages/Home.tsx
Normal file
88
src/components/pages/Home.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { SearchBar } from '@components/molecules/SearchBar';
|
||||
import { PokemonDetails } from '@components/organisms/PokemonDetails';
|
||||
import { usePokemonStore } from '@stores/pokemonStore';
|
||||
import styles from '@styles/app.module.scss';
|
||||
|
||||
export const Home: React.FC = () => {
|
||||
const {
|
||||
pokemon,
|
||||
loading,
|
||||
error,
|
||||
searchTerm,
|
||||
suggestions,
|
||||
suggestionsLoading,
|
||||
setSearchTerm,
|
||||
fetchPokemon,
|
||||
fetchSuggestions,
|
||||
loadAllPokemonNames,
|
||||
clearError,
|
||||
} = usePokemonStore();
|
||||
|
||||
// Load all Pokémon names on component mount
|
||||
useEffect(() => {
|
||||
loadAllPokemonNames();
|
||||
}, [loadAllPokemonNames]);
|
||||
|
||||
const handleSearch = async (value: string) => {
|
||||
if (!value.trim()) return;
|
||||
|
||||
try {
|
||||
await fetchPokemon(value);
|
||||
if (pokemon) {
|
||||
message.success(`Found ${pokemon.name}!`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in handleSearch:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchTermChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
if (error) {
|
||||
clearError();
|
||||
}
|
||||
// Fetch suggestions as user types
|
||||
fetchSuggestions(value);
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
setSearchTerm(selectedValue);
|
||||
handleSearch(selectedValue);
|
||||
};
|
||||
|
||||
// Convert suggestions to AutoComplete options format
|
||||
const options = suggestions.map(name => ({
|
||||
value: name,
|
||||
label: name.charAt(0).toUpperCase() + name.slice(1), // Capitalize first letter
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={styles.searchSection}>
|
||||
<h1>
|
||||
Search for your favorite Pokémon!
|
||||
</h1>
|
||||
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={handleSearchTermChange}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
options={options}
|
||||
loading={suggestionsLoading}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: 'red', textAlign: 'center', marginTop: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PokemonDetails
|
||||
pokemon={pokemon}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
27
src/components/templates/MainLayout.tsx
Normal file
27
src/components/templates/MainLayout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Layout, Typography } from 'antd';
|
||||
import styles from '@styles/app.module.scss';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header className={styles.header}>
|
||||
<Title level={3} className={styles.title}>
|
||||
🎮 Pokémon Explorer
|
||||
</Title>
|
||||
</Header>
|
||||
<Content className={styles.content}>
|
||||
<div className={styles.container}>
|
||||
{children}
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '@styles/global.module.scss'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
2
src/stores/index.ts
Normal file
2
src/stores/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Stores
|
||||
export { usePokemonStore } from './pokemonStore';
|
117
src/stores/pokemonStore.ts
Normal file
117
src/stores/pokemonStore.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { create } from 'zustand';
|
||||
import { getPokemon, getAllPokemonNames } from '@api/get/pokemon';
|
||||
import type { Pokemon } from '@pokemonTypes/pokemon';
|
||||
|
||||
interface PokemonState {
|
||||
// State
|
||||
pokemon: Pokemon | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
searchTerm: string;
|
||||
suggestions: string[];
|
||||
suggestionsLoading: boolean;
|
||||
|
||||
// Actions
|
||||
setPokemon: (pokemon: Pokemon | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setSearchTerm: (term: string) => void;
|
||||
setSuggestions: (suggestions: string[]) => void;
|
||||
setSuggestionsLoading: (loading: boolean) => void;
|
||||
|
||||
// Async actions
|
||||
fetchPokemon: (name: string) => Promise<void>;
|
||||
fetchSuggestions: (searchTerm: string) => Promise<void>;
|
||||
loadAllPokemonNames: () => Promise<void>;
|
||||
clearPokemon: () => void;
|
||||
clearError: () => void;
|
||||
clearSuggestions: () => void;
|
||||
}
|
||||
|
||||
export const usePokemonStore = create<PokemonState>((set, get) => ({
|
||||
// Initial state
|
||||
pokemon: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
searchTerm: '',
|
||||
suggestions: [],
|
||||
suggestionsLoading: false,
|
||||
|
||||
// Actions
|
||||
setPokemon: (pokemon) => set({ pokemon }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setSearchTerm: (searchTerm) => set({ searchTerm }),
|
||||
setSuggestions: (suggestions) => set({ suggestions }),
|
||||
setSuggestionsLoading: (suggestionsLoading) => set({ suggestionsLoading }),
|
||||
|
||||
// Async actions
|
||||
fetchPokemon: async (name: string) => {
|
||||
const { setLoading, setPokemon, setError } = get();
|
||||
|
||||
if (!name.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Intentional bug #2: UI flickering
|
||||
// Setting pokemon to null first causes the previous data to disappear briefly
|
||||
setPokemon(null);
|
||||
|
||||
const pokemon = await getPokemon(name);
|
||||
setPokemon(pokemon);
|
||||
} catch (error) {
|
||||
// Intentional bug #1: No error handling for non-existent Pokémon
|
||||
// This will set a generic error message instead of handling specific cases
|
||||
setError('Failed to fetch Pokémon data');
|
||||
console.error('Error in fetchPokemon:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
|
||||
fetchSuggestions: async (searchTerm: string) => {
|
||||
const { setSuggestions, setSuggestionsLoading } = get();
|
||||
|
||||
if (!searchTerm.trim()) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuggestionsLoading(true);
|
||||
|
||||
try {
|
||||
const allNames = await getAllPokemonNames();
|
||||
const filtered = allNames
|
||||
.filter(name => name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.slice(0, 10); // Limit to 10 suggestions
|
||||
setSuggestions(filtered);
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setSuggestionsLoading(false);
|
||||
}
|
||||
},
|
||||
|
||||
loadAllPokemonNames: async () => {
|
||||
const { setSuggestions, setSuggestionsLoading } = get();
|
||||
|
||||
setSuggestionsLoading(true);
|
||||
|
||||
try {
|
||||
const allNames = await getAllPokemonNames();
|
||||
setSuggestions(allNames);
|
||||
} catch (error) {
|
||||
console.error('Error loading all Pokémon names:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setSuggestionsLoading(false);
|
||||
}
|
||||
},
|
||||
|
||||
clearPokemon: () => set({ pokemon: null }),
|
||||
clearError: () => set({ error: null }),
|
||||
clearSuggestions: () => set({ suggestions: [] }),
|
||||
}));
|
79
src/styles/app.module.scss
Normal file
79
src/styles/app.module.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #001529;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
|
||||
.title {
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.searchSection {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.pokemonCard {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pokemonImage {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.pokemonName {
|
||||
text-transform: capitalize;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pokemonTypes {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.typeTag {
|
||||
margin: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.pokemonStats {
|
||||
margin-top: 16px;
|
||||
|
||||
.statItem {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.statHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.statName {
|
||||
text-transform: capitalize;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
20
src/styles/global.module.scss
Normal file
20
src/styles/global.module.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
@import 'antd/dist/reset.css';
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
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;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
5
src/styles/index.ts
Normal file
5
src/styles/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Global styles
|
||||
export { default as globalStyles } from '@styles/global.module.scss';
|
||||
|
||||
// App-specific styles
|
||||
export { default as appStyles } from '@styles/app.module.scss';
|
39
src/types/pokemon.ts
Normal file
39
src/types/pokemon.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface PokemonType {
|
||||
slot: number;
|
||||
type: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PokemonStat {
|
||||
base_stat: number;
|
||||
effort: number;
|
||||
stat: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PokemonSprites {
|
||||
front_default: string;
|
||||
front_shiny?: string;
|
||||
back_default?: string;
|
||||
back_shiny?: string;
|
||||
}
|
||||
|
||||
export interface Pokemon {
|
||||
id: number;
|
||||
name: string;
|
||||
height: number; // Intentional bug: height is actually optional in the API
|
||||
weight: number;
|
||||
types: PokemonType[];
|
||||
stats: PokemonStat[];
|
||||
sprites: PokemonSprites;
|
||||
base_experience?: number;
|
||||
}
|
||||
|
||||
export interface PokemonApiResponse {
|
||||
data: Pokemon;
|
||||
status: number;
|
||||
}
|
9
src/types/scss.d.ts
vendored
Normal file
9
src/types/scss.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare module '*.module.scss' {
|
||||
const classes: { [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
38
tsconfig.app.json
Normal file
38
tsconfig.app.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@styles/*": ["src/styles/*"],
|
||||
"@pokemonTypes/*": ["src/types/*"],
|
||||
"@api/*": ["src/api/*"],
|
||||
"@stores/*": ["src/stores/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@components': path.resolve(__dirname, './src/components'),
|
||||
'@styles': path.resolve(__dirname, './src/styles'),
|
||||
'@pokemonTypes': path.resolve(__dirname, './src/types'),
|
||||
'@api': path.resolve(__dirname, './src/api'),
|
||||
'@stores': path.resolve(__dirname, './src/stores'),
|
||||
},
|
||||
},
|
||||
})
|
Reference in New Issue
Block a user