mirror of
https://github.com/CarGDev/debug-dojo.git
synced 2025-09-18 18:08: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
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;
|
||||
}
|
Reference in New Issue
Block a user