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:
Carlos
2025-08-03 15:31:55 -04:00
commit e61b8c58fd
31 changed files with 6129 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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/';

View 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>
);
};

View 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>
);
};

View 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
View 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';

View 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..."
/>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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
View File

@@ -0,0 +1,2 @@
// Stores
export { usePokemonStore } from './pokemonStore';

117
src/stores/pokemonStore.ts Normal file
View 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: [] }),
}));

View 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;
}
}
}
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View 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
View 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'),
},
},
})