Pokemon TCG Portfolio Tracker
Build a browser app that tracks your Pokemon card collection with live prices.
Introduction
30 Second Summary
If you have ever dug through a box of Pokemon cards and wondered what they might be worth today, you are not alone. Card values shift constantly with every new set release and tournament season.
In this project, you will build a browser-based Pokemon TCG portfolio tracker that connects to a REST API, pulls live market prices, and calculates the total value of your personal collection.
What You'll Build
You'll search for any Pokemon card, see its real market price alongside the card image, add it to your collection, and check a live dashboard showing your total portfolio value.
By the end of this project, you'll have:
- A card search that queries over 20,000 Pokemon cards by name and displays card images, set info, rarity, and live market prices from TCGplayer.
- A persistent collection stored in your browser's localStorage with quantity tracking, and a portfolio dashboard showing your total collection value calculated from real market prices.
- A set browser with dynamic rarity filtering to discover cards within any expansion.
- Secret Mission: Add condition tracking with Near Mint, Lightly Played, and other grades that adjust each card's displayed value using industry-standard condition multipliers.
Are there any prerequisites?
You'll need basic HTML, CSS, and JavaScript knowledge (variables, functions, events). This project uses no frameworks or build tools. Everything runs by opening a file in your browser.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters to you.
Set Up Your Project and Verify the API
Before building any features, you need a working project structure and proof that the Pokemon TCG API actually responds with real data.
In this step, you'll create your project files, write a basic HTML page, and fire off a test API call. By the end, you'll see real Pokemon card data with market prices right in your browser console.
In this step, get ready to:
- Create the project folder and starter files.
- Build the HTML page structure and verify it loads in the browser.
- Test the Pokemon TCG API and confirm it returns card data with prices.
Create the project folder and files
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and create the starter files by running these commands:
macOS
mkdir pokemon-tcg-tracker
cd pokemon-tcg-tracker
touch index.html style.css app.js
Windows
mkdir pokemon-tcg-tracker
cd pokemon-tcg-tracker
New-Item index.html
New-Item style.css
New-Item app.js
- Confirm the files were created by running:
ls
You should see index.html, style.css, and app.js listed in the output.
Files not showing up?
- Make sure you ran the cd pokemon-tcg-tracker command before creating the files. If you're in the wrong folder, run cd ~/Desktop/pokemon-tcg-tracker and try the file creation commands again.
- On Windows, make sure you're using PowerShell, not Command Prompt. New-Item is a PowerShell command.
- help me troubleshoot why my project files weren't created in the terminal
- Switch back to VS Code.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the pokemon-tcg-tracker folder.
- Click Open.
You should see all three files listed in the Explorer sidebar on the left.
Set up the HTML page structure
Every web app starts with an HTML file that loads the stylesheet and script. Your index.html will define the page layout and pull in style.css and app.js.
- In the VS Code Explorer sidebar, click index.html to open it.
- Set up your page structure by copying this code into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pokemon TCG Portfolio Tracker</title>
<!-- Link the stylesheet -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Pokemon TCG Portfolio Tracker</h1>
</header>
<!-- Empty container for app content -->
<div id="app"></div>
<!-- Load the JavaScript file -->
<script src="app.js"></script>
</body>
</html>
What does this code do?
- The <link> tag connects style.css so the browser knows where to find your styles.
- The <header> and <h1> give the page its visible title.
- The <div id="app"> is an empty container where you'll render card data in later steps.
- The <script> tag loads app.js at the bottom of the body so the HTML elements load first.
- Click style.css in the sidebar and add this placeholder comment:
/* styles will be added in later steps */
- Save both index.html and style.css.
- Open Finder (macOS) or File Explorer (Windows) and navigate to the pokemon-tcg-tracker folder on your Desktop.
- Double-click index.html to open it in your browser.
You should see a page with the heading "Pokemon TCG Portfolio Tracker" at the top.
Seeing a blank page?
- Make sure you saved index.html before opening it. Unsaved changes won't appear in the browser.
- Check that the <h1> tag is inside the <body> section, not inside <head>.
- help me figure out why my index.html page is blank
✔️ Awesome, I've got everything!
Make sure you've saved index.html before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pokemon TCG Portfolio Tracker</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Pokemon TCG Portfolio Tracker</h1>
</header>
<div id="app"></div>
<script src="app.js"></script>
</body>
</html>
Test the API connection
Your page loads, but it doesn't do anything yet. Time to prove the Pokemon TCG API works by fetching some card data and logging it to the console.
- Switch back to VS Code and click app.js in the sidebar to open it.
- Write your API test by adding this code to app.js:
// Test the Pokemon TCG API with a sample query
(async () => {
// Fetch 5 Charizard cards from the API
const response = await fetch('https://api.pokemontcg.io/v2/cards?q=name:charizard&pageSize=5');
// Parse the JSON response
const json = await response.json();
// Log the full response to inspect the data structure
console.log(json);
})();
What does this code do?
- The (async () => { ... })() pattern is an immediately invoked async function. It lets you use await at the top level of your script.
- The fetch() call hits the Pokemon TCG API and searches for cards with "charizard" in the name, limited to 5 results.
- The response.json() method parses the raw HTTP response into a JavaScript object you can explore.
- The console.log(json) prints the full response so you can inspect the data structure in the browser console.
No API key needed
The Pokemon TCG API works without authentication. Without a key, you get 1,000 requests per day and 30 per minute. That's more than enough for this project.
- Save app.js.
Before you check the console, what do you think will appear? Real card data, an error, or nothing at all?
- Switch to your browser and refresh the page.
- Open the browser console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
You should see a JSON object with a data array containing 5 card objects. Expand one of the objects to find fields like name, images, set, tcgplayer (with price data), and rarity.
What is inside the tcgplayer field?
The tcgplayer field contains a prices object with real transaction-based market data from TCGplayer. Each price type (like holofoil or normal) includes a market value representing the current going rate in US dollars.
This is the price data you'll use throughout the project to calculate your portfolio value.
Console is empty or showing an error?
- Make sure you saved app.js and refreshed the browser page. The console only shows results from the latest saved code.
- Check that the <script src="app.js"> tag in index.html matches the filename exactly. A typo like App.js or apps.js will silently fail.
- If you see a CORS or network error, check your internet connection. The API call requires a live connection.
- help me debug why the Pokemon TCG API call is not showing data in my console
✔️ Awesome, I've got everything!
Make sure you've saved app.js before moving on.
ⓧ I'd like to double check the full code
(async () => {
const response = await fetch('https://api.pokemontcg.io/v2/cards?q=name:charizard&pageSize=5');
const json = await response.json();
console.log(json);
})();
Your project structure is solid and the API is confirmed working with real card data and prices. Next up, you'll build a search interface that lets you find any Pokemon card and see its image and market value on screen.
Build the Card Search
In the last step, you proved the Pokemon TCG API is live and responding with real card data. That JSON in your console was the first milestone.
But a console log isn't something you can interact with. In this step, you'll turn that raw API data into a visible, searchable interface where you type a Pokemon name and see real card images, set info, and market prices appear on screen.
In this step, get ready to:
- Add a search form and results grid to your HTML.
- Write JavaScript functions to fetch, price, and display Pokemon cards.
- Style the card grid with responsive CSS.
Replace the placeholder with a search form
The current index.html has an empty <div id="app"> that does nothing. You need a search form the learner can type into, a container for card results, and status messages for loading and empty states.
- In index.html, find this line:
<div id="app"></div>
- Delete that line and paste the following search panel in its place:
<section id="search-panel" class="panel active">
<form id="search-form">
<input type="text" id="search-input" placeholder="Search cards by name..." required>
<button type="submit">Search</button>
</form>
<div id="search-results" class="card-grid"></div>
<p id="search-loading" class="status-msg hidden">Searching...</p>
<p id="search-empty" class="status-msg hidden">No cards found. Try a different search.</p>
</section>
What does this HTML do?
- The <form id="search-form"> wraps the text input and submit button so pressing Enter triggers a search.
- The div#search-results is an empty container that JavaScript will fill with card elements.
- The two <p> tags with class hidden are status messages that JavaScript will show or hide during a search.
- Save index.html.
- Refresh your browser. You should see a text input with placeholder text "Search cards by name..." and a Search button.
Still seeing the blank page?
- Make sure you deleted the entire <div id="app"></div> line. If both the old div and the new section exist, the page may look unexpected.
- Check that the <section> tag sits between </header> and <script> in the file.
- help me figure out why my search form isn't showing up
✔️ Awesome, I've got everything!
Great. Double check you've saved index.html.
ⓧ I'd like to double check the full code
Here is the complete index.html at this point.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pokemon TCG Portfolio Tracker</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Pokemon TCG Portfolio Tracker</h1>
</header>
<section id="search-panel" class="panel active">
<form id="search-form">
<input type="text" id="search-input" placeholder="Search cards by name..." required>
<button type="submit">Search</button>
</form>
<div id="search-results" class="card-grid"></div>
<p id="search-loading" class="status-msg hidden">Searching...</p>
<p id="search-empty" class="status-msg hidden">No cards found. Try a different search.</p>
</section>
<script src="app.js"></script>
</body>
</html>
Write the card search functions
The test code in app.js proved the API works, but it only logs raw JSON. You need reusable functions that fetch cards, extract prices, and build visible DOM elements from the data.
- Select all the code in app.js and replace it with the API base URL and a fetchCards function by pasting this code:
// Base URL for all Pokemon TCG API requests
const API_BASE = "https://api.pokemontcg.io/v2";
// Fetch cards matching a name query from the API
async function fetchCards(query, pageSize = 20) {
const url = `${API_BASE}/cards?q=name:${encodeURIComponent(query)}&pageSize=${pageSize}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`API error: ${response.status}`);
const json = await response.json();
return json.data;
}
What does this code do?
- API_BASE stores the root URL so you don't repeat it in every fetch call.
- fetchCards() takes a search query, URL-encodes it to handle special characters, and returns just the data array from the API response.
- The error check (!response.ok) catches HTTP errors like 404 or 429 (rate limit) before trying to parse JSON.
- Save app.js.
- Refresh your browser and open the console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows).
- Test the new function by typing this into the console and pressing Enter:
fetchCards("pikachu").then(cards => console.log(cards))
You should see an array of card objects logged to the console, each containing name, images, set, and tcgplayer fields.
Seeing an error in the console?
- If you see a syntax error, make sure you replaced ALL the old code. No part of the original (async () => { ... })(); block should remain.
- Check that API_BASE uses straight quotes ("), not curly quotes. Some text editors auto-replace them.
- help me debug my fetchCards function
- Add the price helper functions below fetchCards in app.js by pasting this code at the bottom of the file:
// Extract the best available market price from a card's TCGplayer data
function getMarketPrice(card) {
if (!card.tcgplayer || !card.tcgplayer.prices) return null;
const prices = card.tcgplayer.prices;
// Check price types in priority order: holofoil first, then normal
const types = ["holofoil", "reverseHolofoil", "normal", "1stEditionHolofoil", "1stEditionNormal"];
for (const type of types) {
if (prices[type] && prices[type].market != null) {
return prices[type].market;
}
}
return null;
}
// Format a numeric price as a dollar string, or show a fallback
function formatPrice(price) {
if (price == null) return "Price unavailable";
return `$${price.toFixed(2)}`;
}
Why check multiple price types?
The TCGplayer API returns different price objects depending on the card's variant. A holographic card has prices under holofoil, while a standard print has prices under normal.
By checking types in priority order, getMarketPrice() always picks the most relevant price. Some older or promo cards have no TCGplayer data at all, so the function safely returns null for those.
- Save app.js and refresh your browser.
- Test the price extraction by typing this into the console:
fetchCards("charizard").then(cards => console.log(formatPrice(getMarketPrice(cards[0]))))
You should see a dollar amount like $2.47 or similar printed in the console. The exact value depends on current market data.
- Add the card rendering function below formatPrice in app.js by pasting this code at the bottom of the file:
// Build a card DOM element showing image, details, and price
function createCardElement(card, showAddButton) {
const price = getMarketPrice(card);
const div = document.createElement("div");
div.className = "card-item";
div.innerHTML = `
<img src="${card.images.small}" alt="${card.name}" loading="lazy">
<div class="card-info">
<div class="card-name" title="${card.name}">${card.name}</div>
<div class="card-set">${card.set.name}</div>
<div class="card-rarity">${card.rarity || "Unknown"}</div>
<div class="card-price ${price == null ? "no-price" : ""}">${formatPrice(price)}</div>
</div>
${showAddButton ? `<button class="add-btn" data-card-id="${card.id}">Add to Collection</button>` : ""}
`;
return div;
}
What does createCardElement build?
- It creates a <div> with class card-item containing the card image, name, set, rarity, and market price.
- Cards without rarity data show "Unknown" instead of a blank space.
- Cards without a TCGplayer price get the no-price CSS class, which you'll style differently later.
- The showAddButton parameter controls whether an "Add to Collection" button appears. You'll use this in a later step.
- Add the search handler and event listener below createCardElement in app.js by pasting this code at the bottom of the file:
// Handle form submission: fetch cards and display results
async function handleSearch(event) {
event.preventDefault();
const query = document.getElementById("search-input").value.trim();
if (!query) return;
const resultsContainer = document.getElementById("search-results");
const loadingMsg = document.getElementById("search-loading");
const emptyMsg = document.getElementById("search-empty");
resultsContainer.innerHTML = "";
loadingMsg.classList.remove("hidden");
emptyMsg.classList.add("hidden");
try {
const cards = await fetchCards(query);
loadingMsg.classList.add("hidden");
if (cards.length === 0) {
emptyMsg.classList.remove("hidden");
return;
}
cards.forEach((card) => {
resultsContainer.appendChild(createCardElement(card, true));
});
} catch (error) {
loadingMsg.classList.add("hidden");
resultsContainer.innerHTML = `<p class="status-msg">Error searching cards. Please try again.</p>`;
console.error("Search error:", error);
}
}
document.getElementById("search-form").addEventListener("submit", handleSearch);
What does handleSearch do?
- event.preventDefault() stops the form from reloading the page when submitted.
- It shows a "Searching..." message while waiting for the API, then hides it once results arrive.
- If the API returns zero cards, it shows the "No cards found" message instead of an empty page.
- The last line wires the function to the form's submit event, so pressing Enter or clicking Search triggers it.
Before you test, what do you expect to see on the page when you type "pikachu" and press Enter?
- Save app.js and refresh your browser.
- Type pikachu into the search bar and press Enter.
You should see card images, names, set names, rarity labels, and dollar prices appear on the page. The layout will look rough and unstyled right now. That is expected.
Nothing appears after searching?
- Open the browser console and look for red error text. A common cause is a typo in an element ID. Make sure search-form, search-input, search-results, search-loading, and search-empty match exactly in both your HTML and JavaScript.
- If the page just refreshes when you press Enter, event.preventDefault() may be missing from the top of handleSearch.
- If you see "Searching..." but it never loads, check that the fetchCards function returns json.data (not json alone).
- help me figure out why my card search isn't working
✔️ Awesome, I've got everything!
Great. Double check you've saved app.js.
ⓧ I'd like to double check the full code
Your app.js should contain these four sections in order. Scroll up and confirm each one is present.
- API_BASE constant and fetchCards() function.
- getMarketPrice() and formatPrice() helper functions.
- createCardElement() function.
- handleSearch() function and the addEventListener line at the very bottom.
Style the cards in a responsive grid
The cards work, but they look like a raw list of images and text. A CSS Grid layout will arrange them into a responsive grid that adapts to different screen widths.
- Replace the placeholder comment in style.css with the base page styles by pasting this code:
/* Reset browser defaults and set page theme */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
}
header {
background: #16213e;
padding: 1rem 2rem;
text-align: center;
border-bottom: 2px solid #0f3460;
}
header h1 {
color: #ffcc00;
font-size: 1.5rem;
}
What do these styles do?
The * reset removes browser default spacing so your layout is consistent. The dark blue background (#1a1a2e) and light text give the tracker a polished, dark-themed look.
- Save style.css and refresh your browser.
- You should see a dark blue page with a yellow "Pokemon TCG Portfolio Tracker" heading and a styled header bar.
- Add the search form styles below the header styles in style.css:
/* Search form layout */
#search-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
#search-input {
flex: 1;
padding: 0.6rem 1rem;
border: 1px solid #0f3460;
border-radius: 6px;
background: #16213e;
color: #e0e0e0;
font-size: 1rem;
}
#search-form button {
padding: 0.6rem 1.5rem;
background: #0f3460;
color: #ffcc00;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
}
What do these styles do?
The form uses flexbox to place the input and button side by side. The max-width and auto margins center the search bar on wider screens.
- Save style.css and refresh your browser.
- The search bar should now appear centered with a dark background, light text, and a yellow Search button.
- Add the card grid and card container styles below the search form styles in style.css:
/* Card display grid and visibility */
.panel {
display: none;
padding: 1rem 2rem;
}
.panel.active {
display: block;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.card-item {
background: #16213e;
border-radius: 8px;
overflow: hidden;
border: 1px solid #0f3460;
transition: transform 0.2s;
}
.card-item:hover {
transform: translateY(-2px);
}
.card-item img {
width: 100%;
display: block;
}
What does this grid do?
The auto-fill and minmax(180px, 1fr) combination creates a responsive grid. Cards fill as many columns as fit, each at least 180px wide. On narrow screens, the grid collapses to fewer columns automatically.
- Save style.css and refresh your browser.
- Search for pikachu again. The cards should now snap into a neat grid layout with rounded card containers and images filling each card.
- Add the card text and price styles below the card grid styles in style.css:
/* Card text details and pricing */
.card-info {
padding: 0.5rem;
}
.card-info .card-name {
font-weight: 600;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-info .card-set {
font-size: 0.75rem;
color: #a0a0b0;
}
.card-info .card-rarity {
font-size: 0.7rem;
color: #7a7a9a;
}
.card-info .card-price {
font-size: 0.85rem;
color: #4ade80;
font-weight: 600;
margin-top: 0.25rem;
}
.card-info .card-price.no-price {
color: #7a7a9a;
}
What do these styles do?
Long card names get truncated with an ellipsis instead of breaking the layout. Market prices appear in green (#4ade80), while cards without pricing data get a muted gray color via the no-price class.
- Add the button, status message, and responsive styles below the card text styles in style.css:
/* Add button, status messages, and responsive adjustments */
.card-item .add-btn {
width: 100%;
padding: 0.4rem;
background: #0f3460;
color: #ffcc00;
border: none;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
}
.card-item .add-btn:hover {
background: #1a4a8a;
}
.status-msg {
text-align: center;
color: #7a7a9a;
padding: 2rem;
}
.hidden {
display: none;
}
@media (max-width: 600px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
What do these final styles do?
- The .add-btn spans the full width of each card with a yellow-on-blue color scheme that matches the header.
- The .hidden class controls when loading and empty-state messages appear. JavaScript toggles it on and off.
- The media query shrinks the minimum card width on small screens so the grid stays usable on mobile.
Time for the final test. What do you expect the search results to look like now that all the CSS is in place?
- Save style.css and refresh your browser.
- Type pikachu into the search bar and press Enter.
You should see a polished grid of Pokemon cards. Each card shows the card image, name, set name, rarity, and a green market price from TCGplayer. Cards without pricing display "Price unavailable" in gray. Every card has a yellow "Add to Collection" button at the bottom.
Cards look broken or unstyled?
- Make sure your index.html still has <link rel="stylesheet" href="style.css"> in the <head>.
- If some cards are there but the grid doesn't look right, check that .card-grid in your CSS has display: grid. A missing colon or semicolon can silently break a rule.
- Try a hard refresh (Cmd+Shift+R on macOS, Ctrl+Shift+R on Windows) to clear your browser's cached styles.
- help me fix my card grid styling
✔️ Awesome, I've got everything!
Great. Double check you've saved style.css.
ⓧ I'd like to double check the full code
Your style.css should contain these five sections in order. Scroll up and confirm each one is present.
- Base reset, body, header, and header h1 styles.
- Search form layout: #search-form, #search-input, and #search-form button.
- Panel visibility, card grid, card item containers, and card images.
- Card info text styles: name, set, rarity, price, and .no-price.
- Add button, status messages, hidden utility class, and the mobile media query.
Your search is working and your cards look great. Next up, you'll add the ability to save cards to a personal collection that persists across browser sessions using localStorage.
Add Collection Tracking with localStorage
Your card search pulls real Pokemon cards from the API with images, set info, rarity, and market prices. The Add to Collection buttons are sitting there looking ready to go.
There is a problem though. Refresh the page and everything vanishes. A portfolio tracker that forgets your cards is not much of a tracker. localStorage is a browser feature that stores data on the learner's machine, no server required. In this step, you will wire up those buttons to save cards in localStorage, build a collection view, and add quantity controls.
In this step, get ready to:
- Wire the Add to Collection buttons to save cards in localStorage.
- Build a My Collection panel that displays saved cards.
- Add quantity controls and a remove button to manage your collection.
Save cards to localStorage
Every card you add needs to be stored somewhere that survives a page refresh. localStorage saves key-value pairs directly in the browser, with no server or database needed.
- In app.js, add a storage key constant, a collection state variable, and two helper functions right below the existing API_BASE line by copying this code:
const STORAGE_KEY = "pokemonCollection";
// --- State ---
let collection = [];
// --- localStorage helpers ---
function saveCollection() {
// Convert the collection array to a JSON string and store it
localStorage.setItem(STORAGE_KEY, JSON.stringify(collection));
}
function loadCollection() {
// Read the stored string and parse it back into an array
const data = localStorage.getItem(STORAGE_KEY);
collection = data ? JSON.parse(data) : [];
}
What does this code do?
- STORAGE_KEY is the name used to identify your collection data inside the browser's localStorage.
- collection is an in-memory array that holds your cards while the app runs.
- saveCollection() converts the array to a JSON string and writes it to localStorage.
- loadCollection() reads the JSON string back and parses it into an array. If nothing is stored yet, it starts with an empty array.
- Below the createCardElement function, add the addToCollection function by copying this code:
function addToCollection(card) {
// Check if this card is already in the collection
const existing = collection.find((c) => c.id === card.id);
if (existing) {
// Card exists, just increase the quantity
existing.quantity += 1;
} else {
// New card, store only the fields we need
collection.push({
id: card.id,
name: card.name,
set: card.set.name,
rarity: card.rarity,
imageSmall: card.images.small,
marketPrice: getMarketPrice(card),
quantity: 1,
});
}
saveCollection();
}
What does this function do?
- collection.find() checks whether a card with the same ID already exists in your collection.
- If it exists, the quantity goes up by 1 instead of creating a duplicate entry.
- If it is new, a simplified object is pushed with just the fields the collection needs (not the entire API response).
- saveCollection() writes the updated collection to localStorage immediately after every change.
Now wire the Add to Collection button to call this function. The button HTML already exists from the last step, but nothing happens when you click it yet.
- In app.js, scroll to the createCardElement function and find these lines near the bottom:
`;
return div;
}
- Add the following code between the closing backtick line and return div; so the end of the function looks like this:
`;
if (showAddButton) {
div.querySelector(".add-btn").addEventListener("click", () => addToCollection(card));
}
return div;
}
Finally, wrap the app's startup logic in an init() function that loads any saved collection data when the page opens.
- At the very bottom of app.js, find this standalone line:
document.getElementById("search-form").addEventListener("submit", handleSearch);
- Delete that line and replace it with the init() function by copying this code:
function init() {
// Load saved collection from localStorage on startup
loadCollection();
// Wire up the search form
document.getElementById("search-form").addEventListener("submit", handleSearch);
}
init();
Why wrap everything in init()?
Grouping startup logic in a single function makes it easy to add more initialization steps later. Right now it loads the saved collection and wires the form. In the next substep, you will also render the collection on startup.
Before you test, what do you think will happen when you click Add to Collection on a search result? Will you see a visible change, or will the save happen silently?
- Save app.js.
- Refresh your browser.
- Search for charizard.
- Click the Add to Collection button on any card result.
- Open the browser console with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
- Check the saved data by running this command in the console:
JSON.parse(localStorage.getItem("pokemonCollection"))
Nothing changes visually on the page. But the console shows an array containing one object with the card's name, set, rarity, and market price. The data saved silently to localStorage.
Don't see card data in the console?
- If you see null, make sure you clicked the Add to Collection button on a card before checking.
- If you see an error about addToCollection is not defined, check that the function is placed after createCardElement and before handleSearch in your file.
- Confirm the addEventListener line inside createCardElement calls addToCollection(card) with the card parameter, not the card ID.
Help me debug why my Add to Collection button is not saving data to localStorage.
Build the My Collection panel
Cards are saving to localStorage, but there is no place to see them in the app. This substep adds the HTML container and CSS styles for a collection view.
- In index.html, find the closing </section> tag of the search panel.
- Add the following section below it, right before <script src="app.js"></script>:
<section id="collection-panel" class="panel">
<div id="collection-list"></div>
<p id="collection-empty" class="status-msg">Your collection is empty. Search for cards to add them.</p>
</section>
What does this HTML do?
- div#collection-list is the empty container where JavaScript will render each saved card.
- p#collection-empty shows a message when the collection has no cards. JavaScript will hide it once cards are added.
- The panel starts without the active class, so it is hidden by default. You will add a way to show it later.
- Save index.html.
Now add the CSS that styles each card entry in the collection. The styles are split across two code blocks to keep them manageable.
- In style.css, add these layout and text styles after the .card-item .add-btn:hover rule by copying this code:
/* Layout for each saved card in the collection */
.collection-entry {
display: flex;
align-items: center;
gap: 1rem;
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.collection-entry img {
width: 60px;
border-radius: 4px;
}
.collection-entry .entry-details {
flex: 1;
}
.collection-entry .entry-name {
font-weight: 600;
font-size: 0.9rem;
}
.collection-entry .entry-set {
font-size: 0.75rem;
color: #a0a0b0;
}
- Directly below, add the price display and control styles by copying this code:
/* Price and quantity controls */
.collection-entry .entry-price {
font-size: 0.85rem;
color: #4ade80;
}
.collection-entry .entry-price.no-price {
color: #7a7a9a;
}
.collection-entry .qty-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collection-entry .qty-controls button {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid #0f3460;
background: #0f3460;
color: #e0e0e0;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.collection-entry .qty-controls span {
font-weight: 600; min-width: 20px; text-align: center;
}
- Add the final remove button style right below by copying this code:
.collection-entry .remove-btn {
background: #dc2626;
color: white;
border: none;
padding: 0.3rem 0.6rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
What do these styles do?
Each collection entry is a horizontal flex row with a small card image on the left, text details in the middle, and quantity controls on the right. The + and - buttons are styled as small circles, and the remove button is red to stand out as a destructive action.
- Save style.css.
- Refresh the browser and open the console to confirm there are no errors.
- To peek at the collection panel, run this in the console:
document.getElementById("collection-panel").classList.add("active")
You should see the message "Your collection is empty. Search for cards to add them." appear below the search area. The panel's styles are ready.
Don't see the empty collection message?
- Check that the collection-panel section is placed after the closing </section> of the search panel and before the <script> tag.
- Make sure the section ID is exactly collection-panel with a hyphen, not an underscore.
Help me figure out why my collection panel is not showing up.
Render the collection and add controls
The panel is ready and cards are saving to localStorage. Now you need the JavaScript that reads those saved cards and renders them on screen with interactive quantity controls.
- In app.js, add the removeFromCollection and updateQuantity functions below addToCollection by copying this code:
function removeFromCollection(cardId) {
// Filter out the card with the matching ID
collection = collection.filter((c) => c.id !== cardId);
saveCollection();
renderCollection();
}
function updateQuantity(cardId, delta) {
// Find the card entry to update
const entry = collection.find((c) => c.id === cardId);
if (!entry) return;
entry.quantity += delta;
// Remove the card entirely if quantity drops to 0
if (entry.quantity <= 0) {
removeFromCollection(cardId);
return;
}
saveCollection();
renderCollection();
}
What do these functions do?
- removeFromCollection() filters out the matching card by ID and saves the updated collection.
- updateQuantity() takes a delta value (+1 or -1) to adjust the count. If the quantity drops to zero or below, it calls removeFromCollection instead of leaving a zero-quantity entry.
Now build the renderCollection function that reads the collection array and builds the visible card list. This function is split across two code blocks because of its size.
- Below updateQuantity, start the renderCollection function with its empty-state handling by copying this code:
function renderCollection() {
const container = document.getElementById("collection-list");
const emptyMsg = document.getElementById("collection-empty");
// Show empty message if collection is empty
if (collection.length === 0) {
container.innerHTML = "";
emptyMsg.classList.remove("hidden");
return;
}
// Hide empty message and clear container for fresh render
emptyMsg.classList.add("hidden");
container.innerHTML = "";
}
- Inside renderCollection, add the loop that builds each collection entry. Place this code after container.innerHTML = ""; and before the closing } of the function:
collection.forEach((entry) => {
const div = document.createElement("div");
div.className = "collection-entry";
div.innerHTML = `
<img src="${entry.imageSmall}" alt="${entry.name}">
<div class="entry-details">
<div class="entry-name">${entry.name}</div>
<div class="entry-set">${entry.set} · ${entry.rarity || "Unknown"}</div>
<div class="entry-price ${entry.marketPrice == null ? "no-price" : ""}">
${entry.marketPrice != null ? formatPrice(entry.marketPrice) + " each" : "Price unavailable"}
</div>
</div>
<div class="qty-controls">
<button class="qty-minus" data-id="${entry.id}">-</button>
<span>${entry.quantity}</span>
<button class="qty-plus" data-id="${entry.id}">+</button>
</div>
<button class="remove-btn" data-id="${entry.id}">Remove</button>
`;
div.querySelector(".qty-minus").addEventListener("click", () => updateQuantity(entry.id, -1));
div.querySelector(".qty-plus").addEventListener("click", () => updateQuantity(entry.id, 1));
div.querySelector(".remove-btn").addEventListener("click", () => removeFromCollection(entry.id));
container.appendChild(div);
});
What does renderCollection do?
The function first checks if the collection is empty. If so, it shows the "Your collection is empty" message and stops. Otherwise, it clears the container and rebuilds every entry from scratch.
Each entry gets a small card image, the card name and set, the market price, quantity controls (+ and - buttons), and a red Remove button. Event listeners on each button call updateQuantity or removeFromCollection directly.
Two more small wiring changes will connect everything together.
- In app.js, scroll to the addToCollection function and find this line at the bottom of it:
saveCollection();
}
- Add renderCollection(); right after saveCollection(); so the end of the function looks like this:
saveCollection();
renderCollection();
}
- Scroll to the init() function and find this line:
loadCollection();
document.getElementById("search-form").addEventListener("submit", handleSearch);
- Add renderCollection(); right after loadCollection(); so init looks like this:
function init() {
loadCollection();
renderCollection();
document.getElementById("search-form").addEventListener("submit", handleSearch);
}
init();
Time for the big test. Do you think your previously saved cards (from the localStorage check earlier) will still appear after you refresh, or will they be gone?
- Save app.js.
- Refresh the browser.
- Show the collection panel by running this in the console:
document.getElementById("collection-panel").classList.add("active")
The card you saved earlier should already be visible in the collection panel with its image, name, set, price, and quantity controls. It survived the refresh.
- Search for a different card (try pikachu) and click Add to Collection on one of the results.
- Switch back to the collection panel (run the console command again if needed).
- Click the + button on a card to increase its quantity.
- Click the - button to decrease it.
- Refresh the browser one more time, show the collection panel, and confirm both cards are still there with the correct quantities.
Collection not showing cards after refresh?
- Make sure renderCollection(); is called inside init() right after loadCollection();. Without that call, saved data loads but never renders.
- Check that renderCollection(); is also called at the end of addToCollection. Without it, newly added cards only save to localStorage but do not appear on screen.
- If the + and - buttons do nothing, confirm that updateQuantity and removeFromCollection are defined above renderCollection in your file.
Help me debug why my Pokemon card collection is not rendering or persisting.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: index.html, style.css, and app.js.
ⓧ I'd like to double check the full code
Scroll up and confirm your files contain each of these code blocks in order:
app.js:
- STORAGE_KEY, collection state variable, saveCollection(), and loadCollection() right after API_BASE.
- The event listener inside createCardElement that calls addToCollection(card) when the button is clicked.
- addToCollection() ending with both saveCollection(); and renderCollection();.
- removeFromCollection() and updateQuantity().
- renderCollection() with the empty-state check and the collection.forEach loop inside.
- init() function at the bottom calling loadCollection(), renderCollection(), and wiring the search form.
index.html:
- The collection-panel section with div#collection-list and p#collection-empty placed after the search panel section.
style.css:
- Collection entry layout styles (.collection-entry through .entry-set).
- Price display and control styles (.entry-price through .remove-btn).
Your tracker now remembers every card you add, even after you close and reopen the browser. Next up, you will build a dashboard that adds up all your card prices and shows you what your entire collection is worth.
Build the Portfolio Value Dashboard
Your collection is saved and persists across browser sessions. You can adjust quantities and remove cards. But there is a gap: you have no idea what all those cards are worth together.
This step adds the payoff. You'll build a dashboard at the top of the page that totals up every card's TCGplayer market price times its quantity, showing your portfolio value at a glance.
In this step, get ready to:
- Add a dashboard section with portfolio statistics.
- Display each card's individual value in the collection view.
- Handle cards that have no pricing data.
Add the dashboard section
The dashboard needs to sit at the very top of the page, right below the header, so the learner always sees their portfolio stats.
- In index.html, find the closing </header> tag. Copy the following code directly below it:
<section id="dashboard">
<div class="dashboard-stat">
<span class="stat-label">Unique Cards</span>
<span id="unique-count" class="stat-value">0</span>
</div>
<div class="dashboard-stat">
<span class="stat-label">Total Cards</span>
<span id="total-count" class="stat-value">0</span>
</div>
<div class="dashboard-stat">
<span class="stat-label">Portfolio Value</span>
<span id="portfolio-value" class="stat-value">$0.00</span>
</div>
<div class="dashboard-stat">
<span class="stat-label">Unpriced Cards</span>
<span id="unpriced-count" class="stat-value">0</span>
</div>
</section>
What does this code do?
This creates four stat boxes, each with a label and a value. The IDs (unique-count, total-count, portfolio-value, unpriced-count) give JavaScript a handle to update each number.
All values start at zero. Your JavaScript will calculate the real numbers and fill them in.
- Save index.html and refresh your browser.
- You should see four text items reading "Unique Cards 0", "Total Cards 0", "Portfolio Value $0.00", and "Unpriced Cards 0" below the header. They are unstyled for now.
Don't see the stat text?
- Make sure the new <section id="dashboard"> block sits between </header> and <section id="search-panel">.
- Check that every <div> and <span> has a matching closing tag.
Help me debug my dashboard HTML
Now let's style those stat boxes so they look like an actual dashboard.
- In style.css, find the closing brace of the header h1 rule. Copy the following styles below it:
#dashboard {
display: flex;
gap: 1rem;
padding: 1rem 2rem;
background: #16213e;
flex-wrap: wrap;
justify-content: center;
}
.dashboard-stat {
background: #0f3460;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-align: center;
min-width: 140px;
}
.stat-label {
display: block;
font-size: 0.75rem;
color: #a0a0b0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: #ffcc00;
margin-top: 0.25rem;
}
- Next, scroll to the bottom of style.css and find the existing @media (max-width: 600px) block. Add these rules inside it, below the .card-grid rule:
#dashboard {
padding: 0.75rem 1rem;
gap: 0.5rem;
}
.dashboard-stat {
min-width: 100px;
padding: 0.5rem 0.75rem;
}
What do these styles do?
The main dashboard uses flexbox to lay out the stat boxes in a row that wraps on smaller screens. Each stat box gets a dark blue background with gold text for the values.
The mobile media query reduces the padding and minimum widths so the boxes fit comfortably on narrow screens.
- Save style.css and refresh your browser.
- The four stat boxes now appear in a styled row below the header, each with a dark background, a small grey label on top, and a large gold "0" below it.
Stat boxes look unstyled or stacked oddly?
- Make sure the #dashboard rule uses display: flex and flex-wrap: wrap. Missing either one causes layout issues.
- Verify the new styles are placed after the header h1 block, not inside it.
Help me fix my dashboard styles
Write the portfolio calculation logic
The dashboard boxes are empty shells. You need two functions: one to crunch the numbers, and one to push them into the page.
- In app.js, find the closing brace of renderCollection(). Copy the following two functions below it:
// --- Dashboard ---
function calculatePortfolioValue() {
let totalValue = 0;
let totalCards = 0;
let unpricedCount = 0;
collection.forEach((entry) => {
totalCards += entry.quantity;
if (entry.marketPrice != null) {
totalValue += entry.marketPrice * entry.quantity;
} else {
unpricedCount += entry.quantity;
}
});
return { uniqueCards: collection.length, totalCards, totalValue, unpricedCount };
}
function updateDashboard() {
const stats = calculatePortfolioValue();
document.getElementById("unique-count").textContent = stats.uniqueCards;
document.getElementById("total-count").textContent = stats.totalCards;
document.getElementById("portfolio-value").textContent = formatPrice(stats.totalValue);
document.getElementById("unpriced-count").textContent = stats.unpricedCount;
}
What do these functions do?
The calculatePortfolioValue() function loops through every card in your collection. For each card, it multiplies the market price by the quantity and adds that to the running total. Cards without pricing data are counted separately in unpricedCount so they don't silently drag the total to zero.
The updateDashboard() function calls calculatePortfolioValue() and writes each stat into the matching HTML element by its ID.
The functions exist, but nothing calls them yet. You need to hook updateDashboard() into init() so the dashboard populates when the page loads.
- In app.js, scroll down to init(). Find this line:
renderCollection();
- Add updateDashboard(); on a new line directly below it, so your init() now looks like this:
function init() {
loadCollection();
renderCollection();
updateDashboard();
document.getElementById("search-form").addEventListener("submit", handleSearch);
}
- Save app.js and refresh your browser.
- If you have cards in your collection, the dashboard now shows the correct unique card count, total card count, and portfolio value. If your collection is empty, all values show zero.
Dashboard still showing all zeros with cards in your collection?
- Confirm updateDashboard(); is inside init() after renderCollection();.
- Open the browser console and check for errors. A typo in an element ID (like unique_count instead of unique-count) causes a silent failure.
Help me debug my dashboard calculations
Show individual card values and keep the dashboard in sync
The dashboard shows the total, but each collection entry only shows the single-card price. If you own 3 copies of a card, you want to see the total value for that stack. You also need the dashboard to update live when you add or remove cards, not just on page load.
- In app.js, scroll to renderCollection(). Inside the collection.forEach callback, find this line:
const div = document.createElement("div");
- Add this line directly above it:
// Calculate this entry's total value (price x quantity)
const entryValue = entry.marketPrice != null ? entry.marketPrice * entry.quantity : null;
- Now find the entry-price div inside the same template literal. It currently looks like:
<div class="entry-price ${entry.marketPrice == null ? "no-price" : ""}">
${entry.marketPrice != null ? formatPrice(entry.marketPrice) + " each" : "Price unavailable"}
</div>
- Replace those three lines with:
<div class="entry-price ${entryValue == null ? "no-price" : ""}">
${entry.marketPrice != null ? `${formatPrice(entry.marketPrice)} each · ${formatPrice(entryValue)} total` : "Price unavailable"}
</div>
What changed?
The entryValue variable multiplies the card's market price by how many copies you own. Each collection entry now displays both the per-card price and the total stack value.
For cards with no pricing, entryValue is null, so it shows "Price unavailable" and gets the no-price class for grey styling.
- Save app.js and refresh your browser.
- Each card in your collection now shows something like "$12.50 each · $37.50 total" if you own 3 copies.
The dashboard currently only updates on page load. If you add a card or change a quantity, the stat boxes stay stale until you refresh. Let's fix that.
- In app.js, find addToCollection(). At the end of the function, right after renderCollection();, add:
updateDashboard();
- Find removeFromCollection(). Add the same updateDashboard(); line after renderCollection();.
- Find updateQuantity(). Add updateDashboard(); after the renderCollection(); at the bottom of that function (the one that runs when quantity stays above zero).
Why add it to all three functions?
Every action that changes the collection (adding a card, removing one, or adjusting the quantity) needs to recalculate the portfolio stats. Calling updateDashboard() in each function keeps the stat boxes in sync at all times.
- Save app.js.
Before you test, think about this: if you add 3 cards with different quantities, what do you expect the dashboard total to show?
- Refresh your browser.
- Search for a card (try "pikachu") and add one to your collection.
- Search for a different card (try "charizard") and add it.
- Use the + button to give one of your cards a quantity of 3.
The dashboard updates in real time. Unique Cards shows how many different cards you own. Total Cards is the sum of all quantities. Portfolio Value is the sum of each card's market price times its quantity, formatted as a dollar amount.
How does the dashboard handle cards with no price?
Some cards in the Pokemon TCG API have no TCGplayer pricing data. Your calculatePortfolioValue() function skips these when summing the total and counts them separately in unpricedCount.
The "Unpriced Cards" stat tells you how many cards couldn't be valued. In the collection view, those entries show "Price unavailable" instead of a dollar amount, so you always know where the gaps are.
Dashboard not updating when you add or remove cards?
- Confirm you added updateDashboard(); to all three functions: addToCollection(), removeFromCollection(), and updateQuantity().
- In updateQuantity(), make sure updateDashboard(); is after the final renderCollection(); (not inside the if (entry.quantity <= 0) block).
- Check the browser console for errors. A missing function name or typo will stop the update.
Help me wire up my dashboard updates
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files: index.html, style.css, and app.js.
ⓧ I'd like to double check the full code
Here is everything you should have at the end of this step. Scroll up and confirm each piece is present.
In index.html:
- A <section id="dashboard"> block with four .dashboard-stat divs, sitting between </header> and <section id="search-panel">.
In style.css:
- Rules for #dashboard, .dashboard-stat, .stat-label, and .stat-value after the header h1 block.
- Mobile overrides for #dashboard and .dashboard-stat inside the existing @media block.
In app.js:
- A calculatePortfolioValue() function and an updateDashboard() function after renderCollection().
- The renderCollection() forEach now calculates entryValue and shows "each · total" for priced cards.
- Calls to updateDashboard(); at the end of addToCollection(), removeFromCollection(), updateQuantity(), and inside init().
Your dashboard is live, and you can see exactly what your collection is worth. Next up, you'll add set browsing so you can explore cards by expansion and find cards you are missing.
Add Set Browsing and Rarity Filtering
Your portfolio tracker now calculates and displays what your collection is worth. But right now, searching by name is the only way to find cards.
Collectors also browse by set to see what's available in an expansion and spot cards they're missing. In this step, you'll add set-based exploration with rarity filtering and a clean toggle to switch between discovery modes.
In this step, get ready to:
- Fetch and display every Pokemon TCG set in a dropdown selector.
- Add a rarity filter that narrows results within a selected set.
- Build a mode toggle to switch between Search, Browse Sets, and My Collection views.
Add the browse panel and navigation HTML
Before writing any JavaScript, you need HTML elements for the mode toggle and the browse panel where set results will appear.
- In index.html, find the closing </section> tag for the dashboard section.
- Add this navigation bar directly below it:
<nav id="mode-toggle">
<button class="mode-btn active" data-mode="search">Search</button>
<button class="mode-btn" data-mode="browse">Browse Sets</button>
<button class="mode-btn" data-mode="collection">My Collection</button>
</nav>
- Now find the closing </section> tag for the search panel (the one right after search-empty).
- Add the browse panel directly below it:
<section id="browse-panel" class="panel active">
<div id="browse-controls">
<select id="set-select">
<option value="">Select a set...</option>
</select>
<select id="rarity-filter">
<option value="">All Rarities</option>
</select>
</div>
<div id="browse-results" class="card-grid"></div>
<p id="browse-loading" class="status-msg hidden">Loading set...</p>
</section>
Why add active to the browse panel?
You're temporarily adding the active class so the browse panel is visible while you build and test it. Both the search panel and browse panel will show at the same time for now.
In the final substep, you'll implement a switchMode function that properly toggles which panel is visible, and you'll remove this temporary class.
- Save index.html and refresh your browser.
- You should see three toggle buttons (Search, Browse Sets, My Collection) below the dashboard, and two unstyled dropdowns below the search bar.
Don't see the buttons or dropdowns?
Make sure you placed the <nav> block between the dashboard </section> and the search panel <section>. The browse panel goes between the search panel and collection panel.
Check that the browse panel has class="panel active" so it's visible for now.
Help me debug my index.html layout
Style the mode toggle and browse controls
The buttons and dropdowns need styling to match your dark theme. You'll add two groups of CSS rules.
- In style.css, find the .panel.active rule near the bottom of the panel styles.
- Add the mode toggle styles directly below it:
/* Navigation toggle between modes */
#mode-toggle {
display: flex;
gap: 0;
padding: 1rem 2rem 0;
justify-content: center;
}
.mode-btn {
background: #16213e;
color: #a0a0b0;
border: 1px solid #0f3460;
padding: 0.5rem 1.5rem;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s, color 0.2s;
}
.mode-btn:first-child {
border-radius: 6px 0 0 6px;
}
.mode-btn:last-child {
border-radius: 0 6px 6px 0;
}
/* Highlight the currently active mode */
.mode-btn.active {
background: #0f3460;
color: #ffcc00;
}
What does this CSS do?
The .mode-btn rules create a segmented control where the buttons sit flush against each other. The :first-child and :last-child selectors round only the outer corners, giving them a pill shape.
The .active class highlights whichever mode is selected with your yellow accent color.
- Save style.css and refresh your browser.
- The three toggle buttons should now appear as a styled segmented control with Search highlighted in yellow.
- Now add the browse controls styles below the #search-form button rule:
/* Set and rarity dropdown row */
#browse-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
/* Style both select dropdowns */
#browse-controls select {
flex: 1;
padding: 0.6rem 1rem;
border: 1px solid #0f3460;
border-radius: 6px;
background: #16213e;
color: #e0e0e0;
font-size: 0.9rem;
}
- Save style.css and refresh your browser.
- Scroll down past the search bar. You should see the two dropdowns styled with a dark background and rounded corners, matching the search input.
Fetch sets and load cards by set
The Pokemon TCG API serves a sets endpoint that returns every expansion ever released. You'll fetch that list on page load and let the user pick one to browse its cards.
- Switch back to app.js.
- Find the line let collection = []; near the top of the file.
- Add this state variable on the line directly below it:
let currentSetCards = [];
- Now find the closing brace of the fetchCards function.
- Add these two new API functions directly below it:
// Fetch all cards from a specific set by its ID
async function fetchCardsBySet(setId) {
const url = `${API_BASE}/cards?q=set.id:${setId}&pageSize=250`;
const response = await fetch(url);
if (!response.ok) throw new Error(`API error: ${response.status}`);
const json = await response.json();
return json.data;
}
// Fetch all sets, ordered by newest release first
async function fetchSets() {
const url = `${API_BASE}/sets?orderBy=-releaseDate&pageSize=250`;
const response = await fetch(url);
if (!response.ok) throw new Error(`API error: ${response.status}`);
const json = await response.json();
return json.data;
}
What do these functions do?
Both functions follow the same pattern as your existing fetchCards function.
- fetchCardsBySet uses the query set.id:${setId} to pull up to 250 cards from a single expansion.
- fetchSets hits the /v2/sets endpoint with orderBy=-releaseDate so the newest sets appear first in the dropdown.
- Now find the closing brace of the handleSearch function (near the bottom of your file, above init).
- Add these rendering and loading functions below it:
// Render an array of cards into the browse results grid
function renderSetCards(cards) {
const container = document.getElementById("browse-results");
container.innerHTML = "";
cards.forEach((card) => {
container.appendChild(createCardElement(card, true));
});
}
// Fetch all sets and populate the set dropdown on page load
async function loadSets() {
const setSelect = document.getElementById("set-select");
try {
const sets = await fetchSets();
sets.forEach((set) => {
const option = document.createElement("option");
option.value = set.id;
// Display set name and release date in the dropdown
option.textContent = `${set.name} (${set.releaseDate})`;
setSelect.appendChild(option);
});
} catch (error) {
console.error("Error loading sets:", error);
}
}
What does this code do?
- renderSetCards reuses your existing createCardElement function to display cards in the same grid format as search results.
- loadSets calls fetchSets and creates an <option> for each set, showing the set name alongside its release date.
- In your init function, add loadSets(); on the line after updateDashboard();.
- Save app.js and refresh your browser.
- Click the set dropdown. You should see a long list of Pokemon TCG sets with names and release dates, newest first.
Dropdown is empty?
Open the browser console and check for errors. A 404 or network error means the API URL is wrong. Double-check that fetchSets uses the URL ending in /sets?orderBy=-releaseDate&pageSize=250.
Make sure you added loadSets(); inside the init function body, not outside it.
Help me debug my set dropdown
- Now add the handleSetChange function below loadSets to load a set's cards when the user picks one:
// Load cards when the user selects a set
async function handleSetChange(event) {
const setId = event.target.value;
if (!setId) return;
const resultsContainer = document.getElementById("browse-results");
const loadingMsg = document.getElementById("browse-loading");
const rarityFilter = document.getElementById("rarity-filter");
// Clear previous results and show loading indicator
resultsContainer.innerHTML = "";
loadingMsg.classList.remove("hidden");
rarityFilter.innerHTML = '<option value="">All Rarities</option>';
try {
currentSetCards = await fetchCardsBySet(setId);
loadingMsg.classList.add("hidden");
// Build the rarity filter from unique rarities in this set
const rarities = [...new Set(currentSetCards.map((c) => c.rarity).filter(Boolean))].sort();
rarities.forEach((rarity) => {
const option = document.createElement("option");
option.value = rarity;
option.textContent = rarity;
rarityFilter.appendChild(option);
});
renderSetCards(currentSetCards);
} catch (error) {
loadingMsg.classList.add("hidden");
resultsContainer.innerHTML = `<p class="status-msg">Error loading set. Please try again.</p>`;
console.error("Set load error:", error);
}
}
What does handleSetChange do?
- It clears any previous results and shows a loading message while the API request is in flight.
- It resets the rarity filter dropdown to "All Rarities" each time a new set is selected.
- After loading, it stores the full card array in currentSetCards so the rarity filter can work client-side without another API call.
- It extracts all unique rarity values from the loaded cards and populates the rarity dropdown dynamically.
- Still in the init function, add this line after the search-form event listener:
document.getElementById("set-select").addEventListener("change", handleSetChange);
- Save app.js and refresh your browser.
- Select any set from the dropdown. After a brief loading message, cards from that set should fill the grid with images, names, and prices.
Cards not loading when you select a set?
Check the browser console for errors. If you see handleSetChange is not defined, make sure the function is declared above the init function.
If nothing happens when you select a set, verify the event listener targets set-select (the ID on your <select> in the HTML) and listens for the change event.
Help me debug set browsing
Add rarity filtering
The rarity dropdown is already populated whenever you pick a set. Now you need to make it actually filter the displayed cards when the user selects a rarity.
- In app.js, add the handleRarityFilter function below handleSetChange:
// Filter the currently loaded set cards by the selected rarity
function handleRarityFilter(event) {
const rarity = event.target.value;
const filtered = rarity ? currentSetCards.filter((c) => c.rarity === rarity) : currentSetCards;
renderSetCards(filtered);
}
What does this code do?
This filter works entirely client-side. The full card array is already stored in currentSetCards from the previous API call. When the user picks a rarity, the function filters that array and re-renders. No extra API request needed.
If the user selects "All Rarities" (an empty value), the function shows every card in the set.
- In your init function, add this line after the set-select listener you added earlier:
document.getElementById("rarity-filter").addEventListener("change", handleRarityFilter);
- Save app.js and refresh your browser.
- Select a set from the dropdown to load its cards.
- Now pick a rarity from the rarity dropdown. Only cards matching that rarity should remain in the grid.
- Switch back to All Rarities to confirm all cards reappear.
Rarity filter not working?
Make sure the event listener targets rarity-filter (matching the ID on your <select> element) and listens for change.
If the rarity dropdown has no options, check that handleSetChange includes the block that extracts rarities from currentSetCards and appends them as <option> elements.
Help me debug my rarity filter
Build the mode toggle
Right now both the search panel and browse panel show at the same time. The final piece is a switchMode function that shows only one panel at a time when the user clicks a toggle button.
- In index.html, find the opening tag of your browse panel:
<section id="browse-panel" class="panel active">
- Remove active from the class so it reads:
<section id="browse-panel" class="panel">
- Save index.html.
- Now switch to app.js and add the switchMode function below renderSetCards:
// Switch between Search, Browse Sets, and My Collection panels
function switchMode(mode) {
document.querySelectorAll(".mode-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.mode === mode);
});
document.querySelectorAll(".panel").forEach((panel) => {
panel.classList.toggle("active", panel.id === `${mode}-panel`);
});
}
What does switchMode do?
It loops through every mode button and every panel. For each, it adds the active class only to the element matching the selected mode, and removes it from all others.
The data-mode attribute on each button maps to a panel ID. Clicking Browse Sets (data-mode="browse") shows browse-panel and hides the others.
- In your init function, add these lines after the rarity-filter listener:
document.querySelectorAll(".mode-btn").forEach((btn) => {
btn.addEventListener("click", () => switchMode(btn.dataset.mode));
});
- Save app.js and refresh your browser.
Before you click the toggle buttons, what do you expect to happen? Will both panels still show at once, or will only one appear?
- Click Browse Sets. The search bar disappears and the set dropdown appears.
- Click My Collection. Your saved collection replaces the browse view.
- Click Search. The search bar comes back.
Only one panel shows at a time, and the active button highlights in yellow to show which mode you're in.
Buttons not switching panels?
Make sure you removed active from the browse panel's class attribute in index.html. If both panels still show, the HTML class override is preventing switchMode from working correctly.
Confirm that each button's data-mode value matches the corresponding panel ID pattern: search maps to search-panel, browse maps to browse-panel, and collection maps to collection-panel.
Help me debug my mode toggle
Time for the full test. Before you try this, predict: if you select a set, then pick a rarity, will the filter narrow the grid down to just that rarity?
- Click Browse Sets in the mode toggle.
- Select any set from the dropdown.
- Wait for the cards to load in the grid.
- Select a rarity from the rarity dropdown (for example, pick a holofoil or rare option).
- Only cards matching that rarity should remain. Switch back to All Rarities and every card in the set reappears.
✔️ Awesome, I've got everything!
Great. Double-check you've saved all three files: index.html, style.css, and app.js.
ⓧ I'd like to double check the full code
Each file is long, so scroll through your code and confirm each section is present and in order.
index.html should have, in order:
- The <header> with the page title.
- The #dashboard section with four stat boxes.
- The <nav id="mode-toggle"> with three buttons (Search, Browse Sets, My Collection).
- The #search-panel with the search form, results grid, loading text, and empty text.
- The #browse-panel (with class="panel", no active) containing #browse-controls with two selects, #browse-results, and #browse-loading.
- The #collection-panel with the collection list and empty message.
- The <script> tag loading app.js.
style.css should include:
- All existing styles (reset, body, header, dashboard, search form, panels, card grid, card items, collection entries, status messages, responsive breakpoint).
- New #mode-toggle and .mode-btn rules (including :first-child, :last-child, and .active).
- New #browse-controls and #browse-controls select rules.
app.js should have these functions in order:
- Constants (API_BASE, STORAGE_KEY) and state variables (collection, currentSetCards).
- saveCollection, loadCollection.
- fetchCards, fetchCardsBySet, fetchSets.
- getMarketPrice, formatPrice, createCardElement.
- addToCollection, removeFromCollection, updateQuantity, renderCollection.
- calculatePortfolioValue, updateDashboard.
- handleSearch.
- loadSets, handleSetChange, handleRarityFilter, renderSetCards.
- switchMode.
- init (calling loadSets() and wiring listeners for set-select, rarity-filter, and .mode-btn clicks).
Your tracker now has two powerful ways to discover cards: searching by name and browsing by set with rarity filtering. The mode toggle keeps both paths organized in a clean, non-cluttered interface.
Secret mission
Add Condition Tracking with Value Adjustment
Your portfolio tracker treats every card as if it just came out of the pack. In reality, a Near Mint Charizard is worth far more than a Heavily Played one. In this mission, you'll add condition tracking with industry-standard multipliers that adjust each card's displayed value and your portfolio total.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files and collection data, or remove them entirely.
Resources you used:
- Project files on your Desktop (index.html, style.css, app.js).
- Collection data stored in your browser's localStorage under the key pokemonCollection.
What about the Pokemon TCG API?
You did not create an account or API key for this project. The Pokemon TCG API is free and public, so there is nothing to cancel or delete on the API side.
Keep everything running
No action needed. Choose this if you want to keep building on your portfolio tracker or refer back to the code later.
- Your project files stay on your Desktop wherever you created them.
- Your card collection persists automatically in localStorage. Open index.html in the same browser anytime to pick up where you left off.
A note on localStorage
Your collection data is tied to the specific browser and file path you used. If you move the project folder or switch browsers, your collection will not carry over.
Pause - I'll come back to this later
There are no running processes or servers to stop. Your project is just static files that run in the browser, so pausing is the same as keeping everything.
- Leave your project files on your Desktop.
- Your collection data stays in localStorage until you clear it or clear your browser data.
Delete - I don't want to use this again
Remove all project files and clear your saved collection data to start fresh.
- Clear your saved collection by opening your browser's developer console and running the command below.
localStorage.removeItem('pokemonCollection')
- Delete your project folder from your Desktop. On macOS or Linux, open your terminal and run:
cd ~/Desktop
rm -rf pokemon-tcg-tracker
On Windows, open PowerShell and run:
cd ~\Desktop
Remove-Item -Recurse -Force pokemon-tcg-tracker
Folder name might differ
Replace pokemon-tcg-tracker with the actual name of the project folder you created on your Desktop in Step 1.
Nice Work!
Nice Work!
Nice work! You just built a full Pokemon TCG portfolio tracker that pulls real market prices, tracks your collection across browser sessions, and calculates what your cards are worth.
You learned how to:
- Built a card search interface that queries the Pokemon TCG API and displays card images, set info, rarity, and live market prices from TCGplayer.
- Implemented persistent collection tracking using localStorage with quantity management and a portfolio value dashboard that calculates total collection worth from real TCGplayer market prices.
- Added set-based browsing with a dynamic rarity filter for discovering cards across any expansion.
- Secret Mission: Added condition tracking with industry-standard multipliers that adjust individual card values and the portfolio total.
Ready to quiz yourself?