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