Build a Cologne Dupe Finder App

Build a filterable web app to find affordable cologne alternatives.

Introduction

30 Second Summary

Finding affordable alternatives to luxury colognes usually means scrolling through dozens of forums and review sites. What if you had a single tool that showed you the best dupes, complete with performance comparisons, all in one place?

In this project, you will build an interactive web app that displays expensive colognes alongside their affordable alternatives. You will add real-time search and filtering so users can find their perfect match instantly.

What You'll Build

A responsive cologne dupe finder web application built with vanilla HTML, CSS, and JavaScript.

By the end of this project, you'll have:

  • A styled card-based layout using CSS Grid with a dark luxury theme and hover animations.
  • Dynamic card rendering powered by JavaScript arrays, template literals, and DOM manipulation.
  • Real-time search and filter functionality that instantly narrows results across multiple fields.
  • Secret Mission: Add sorting options and display dollar savings on each dupe card.

Before We Start

You're about to build a web app that shows expensive colognes next to their affordable alternatives. Each card displays performance metrics like longevity and projection strength, so you can compare at a glance.

Before writing any app code, you need a few tools in place. In this step, you'll install a live preview extension, create your project folder, and confirm everything is working in the browser.

In this step, get ready to:
  • Install the Live Server extension in VS Code for automatic browser refresh.
  • Create your project folder with three starter files.
  • Verify your setup by viewing a test heading in Chrome.
Install the Live Server extension

Live Server auto-refreshes your browser every time you save a file. This means you can see your changes instantly without manually reloading the page.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Type Live Server (Five Server) into the search bar.
  • Find the result by Yannick and click Install.

What is Live Server?

Live Server (Five Server) is a VS Code extension that launches a local development server. It watches your files for changes and automatically reloads the browser when you save.

Once installed, you'll see a Go Live button in the bottom status bar of VS Code. That button is how you'll launch your project in Chrome.

  • Confirm you see Go Live in the blue status bar at the bottom of VS Code.

Don't see Go Live?

Make sure you installed the extension by Yannick (the publisher name appears below the extension title). Try closing and reopening VS Code if the button still doesn't appear.

Help me find and install the Live Server extension in VS Code.

Create your project folder and files

Every web project lives inside a folder that holds all its files together. You'll create one folder with three files inside: one for structure, one for styling, and one for interactivity.

  • Open the VS Code terminal by pressing Ctrl+` (backtick key, located above Tab).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir cologne-dupe-finder
cd cologne-dupe-finder
  • Create the three project files by running this command:
touch index.html style.css script.js

What do these three files do?

  • index.html defines the page structure (headings, buttons, inputs).
  • style.css controls how everything looks (colors, layout, spacing).
  • script.js adds interactivity (filtering, searching, rendering cards).
  • Confirm the files exist by running:
ls

You should see index.html, style.css, and script.js in the output.

  • Open the cologne-dupe-finder folder in VS Code by running:
code .

Seeing 'command not found' for code?

On macOS, you need to install the code command manually. Open the Command Palette with Cmd+Shift+P, type shell command, and select Shell Command: Install 'code' command in PATH. Then restart your terminal and try again.

Alternatively, open the folder through the menu: click File then Open Folder, and navigate to Desktop > cologne-dupe-finder.

Help me open my project folder in VS Code.

Add a basic HTML skeleton and launch Live Server

With your folder open in VS Code, you can now write a minimal HTML page to confirm everything is wired up. This skeleton links your CSS and JavaScript files so they're ready when you need them.

  • In the VS Code Explorer sidebar (left panel), click index.html to open it.
  • Paste the following 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>Cologne Dupe Finder</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>Cologne Dupe Finder</h1>
    <script src="script.js"></script>
</body>
</html>

What does this code do?

  • <!DOCTYPE html> tells the browser this is a modern HTML5 page.
  • <meta name="viewport"> makes the page responsive on mobile screens.
  • <link rel="stylesheet" href="style.css"> connects your stylesheet so styles apply when you add them.
  • <script src="script.js"> loads your JavaScript file at the end of the body so it runs after the page content loads.
  • Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Click Go Live in the bottom status bar of VS Code.

Chrome opens automatically and displays your "Cologne Dupe Finder" heading served from localhost.

Don't see the heading in Chrome?

Make sure you saved index.html before clicking Go Live. If a different browser opened instead of Chrome, copy the URL from the address bar (it starts with http://127.0.0.1) and paste it into Chrome.

Help me get Live Server running and see my page in Chrome.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and your heading is visible in Chrome.

ⓧ I'd like to double check the full code

Here's what index.html should look like 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>Cologne Dupe Finder</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>Cologne Dupe Finder</h1>
    <script src="script.js"></script>
</body>
</html>

Your style.css and script.js files should be empty for now.

Your development environment is ready. Next up, you'll build out the full HTML structure with filter controls and a card grid.

Build the HTML Structure

Your project folder is set up and Live Server is running. Now it's time to build the actual structure of your app.

Every web app starts with HTML that defines what elements appear on the page. You'll create three distinct sections: a header for the app title, a filter bar with search and dropdown controls, and an empty grid container that JavaScript will fill with cologne cards later.

In this step, get ready to:
  • Create a semantic page header with a title and subtitle.
  • Build a filter controls section with a search input and four dropdown menus.
  • Add an empty grid container for dynamically generated cologne cards.
Create the page header

Right now your page has a bare <h1> tag floating directly inside the body. Wrapping it in a <header> element gives the page a proper semantic container for its title area.

  • In index.html, find the line that says:
    <h1>Cologne Dupe Finder</h1>
  • Replace that line with this header element:
    <header>
        <h1>Cologne Dupe Finder</h1>
        <p>Find affordable alternatives to expensive colognes</p>
    </header>

What does this code do?

The <header> element groups your title and subtitle into one semantic unit. Screen readers and search engines use this to identify the page's top-level introduction.

The <p> tag underneath the heading gives users a quick description of what this app does.

  • Save index.html.
  • Check your browser. You should see the "Cologne Dupe Finder" heading followed by the subtitle "Find affordable alternatives to expensive colognes" underneath it.

Don't see the subtitle?

Make sure the <p> tag sits inside the <header> opening and closing tags, not outside them. Also confirm you saved the file.

Help me fix my header structure in index.html

Build the filter controls section

Users need a way to search and filter the cologne list. You'll create a dedicated section with a text input for searching by name and four dropdown menus for filtering by season, scent family, projection strength, and longevity.

  • In index.html, add the following code directly below the closing </header> tag:
    <section class="filters">
        <input type="text" id="search-input" placeholder="Search by cologne or brand name...">
        <select id="season-filter">
            <option value="all">All Seasons</option>
            <option value="Spring">Spring</option>
            <option value="Summer">Summer</option>
            <option value="Fall">Fall</option>
            <option value="Winter">Winter</option>
        </select>
        <select id="scent-filter">
            <option value="all">All Scent Families</option>
            <option value="Fruity Chypre">Fruity Chypre</option>
            <option value="Aromatic Fougere">Aromatic Fougere</option>
            <option value="Oriental Amber">Oriental Amber</option>
            <option value="Woody Aromatic">Woody Aromatic</option>
            <option value="Woody Oud">Woody Oud</option>
            <option value="Spicy Woody">Spicy Woody</option>
        </select>
        <select id="projection-filter">
            <option value="all">All Projections</option>
            <option value="Soft">Soft</option>
            <option value="Moderate">Moderate</option>
            <option value="Strong">Strong</option>
        </select>
        <select id="longevity-filter">
            <option value="all">Any Longevity</option>
            <option value="6">6+ Hours</option>
            <option value="8">8+ Hours</option>
            <option value="10">10+ Hours</option>
        </select>
    </section>

What does this code do?

  • The <section class="filters"> groups all filter controls into one container. The class name lets you style them together later with CSS.
  • The text <input> with id="search-input" gives users a freeform search box. JavaScript will use that ID to read whatever the user types.
  • Each <select> dropdown has a default value="all" option that shows everything, plus specific filter values the user can choose.
  • The IDs on each select (season-filter, scent-filter, projection-filter, longevity-filter) let JavaScript grab the selected value later.
  • Save index.html and check your browser.
  • You should see a text input with placeholder text and four dropdown menus. Click each dropdown to confirm it shows its options (seasons, scent families, projection levels, and longevity hours).

Dropdowns not showing options?

Check that every <select> has a matching </select> closing tag. A missing closing tag causes all the options below it to disappear or merge into the wrong dropdown.

Also confirm the entire block sits inside the <section class="filters"> and its </section> closing tag.

Help me fix my filter dropdowns in index.html

Add the cologne grid container

The last piece of structure is an empty container where JavaScript will dynamically inject cologne cards. You won't see anything visible from this element yet, but it's the target that your rendering function will fill in a later step.

  • In index.html, add the following code directly below the closing </section> tag (and above the <script> tag):
    <main id="cologne-grid" class="cologne-grid">
    </main>

Why use a main element?

The <main> element tells browsers and screen readers that this is the primary content area of the page. The id="cologne-grid" gives JavaScript a hook to find this element and inject cards into it.

  • Save index.html.
  • You won't see a visible change in the browser since the main element is empty. Open DevTools (right-click the page and select Inspect) and look in the Elements tab. You should see a <main id="cologne-grid" class="cologne-grid"> element in the DOM.

✔️ Awesome, I've got everything!

Great. Double check you have saved index.html and your browser shows the header, filters, and an empty area below.

ⓧ I'd like to double check the full code

Here is the complete index.html file at this point. Compare yours against it:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Cologne Dupe Finder</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>Cologne Dupe Finder</h1>
        <p>Find affordable alternatives to expensive colognes</p>
    </header>

    <section class="filters">
        <input type="text" id="search-input" placeholder="Search by cologne or brand name...">
        <select id="season-filter">
            <option value="all">All Seasons</option>
            <option value="Spring">Spring</option>
            <option value="Summer">Summer</option>
            <option value="Fall">Fall</option>
            <option value="Winter">Winter</option>
        </select>
        <select id="scent-filter">
            <option value="all">All Scent Families</option>
            <option value="Fruity Chypre">Fruity Chypre</option>
            <option value="Aromatic Fougere">Aromatic Fougere</option>
            <option value="Oriental Amber">Oriental Amber</option>
            <option value="Woody Aromatic">Woody Aromatic</option>
            <option value="Woody Oud">Woody Oud</option>
            <option value="Spicy Woody">Spicy Woody</option>
        </select>
        <select id="projection-filter">
            <option value="all">All Projections</option>
            <option value="Soft">Soft</option>
            <option value="Moderate">Moderate</option>
            <option value="Strong">Strong</option>
        </select>
        <select id="longevity-filter">
            <option value="all">Any Longevity</option>
            <option value="6">6+ Hours</option>
            <option value="8">8+ Hours</option>
            <option value="10">10+ Hours</option>
        </select>
    </section>

    <main id="cologne-grid" class="cologne-grid">
    </main>

    <script src="script.js"></script>
</body>
</html>

Your page structure is locked in. Next up, you'll style everything with CSS to create a polished dark luxury theme that makes those filter controls and cards look professional.

Style the App with CSS

Your HTML structure is complete. The page works, but it looks like a plain document from the 1990s.

In this step, you will transform your page into a polished, dark-themed luxury interface using CSS. You will define a reusable color palette, style the filter bar, and prepare a responsive grid layout for the cologne cards.

In this step, get ready to:
  • Set up CSS custom properties for a dark luxury color scheme.
  • Style the header and filter bar with flexbox layout.
  • Create a responsive card grid using CSS Grid.
Set up custom properties and base styles

CSS custom properties let you define colors once and reuse them throughout your stylesheet. Instead of repeating hex codes everywhere, you reference variables like var(--accent-gold). Changing a color later means updating one line instead of dozens.

  • In VS Code, open style.css and add the reset, custom properties, and body styles by pasting this code:
/* Reset default browser spacing */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

/* Color palette for the dark luxury theme */
:root {
    --bg-primary: #1a1a2e;
    --bg-secondary: #16213e;
    --bg-card: #0f3460;
    --accent-gold: #d4a574;
    --accent-green: #4ecca3;
    --accent-red: #e94560;
    --text-primary: #ffffff;
    --text-secondary: #a8a8b3;
    --border: #2a2a4a;
}

/* Base page styles */
body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background-color: var(--bg-primary);
    color: var(--text-primary);
    min-height: 100vh;
    padding: 2rem;
}

What does this code do?

The * selector resets all elements to zero margin and padding, giving you full control over spacing. box-sizing: border-box makes padding count inside an element's width instead of adding to it.

The :root block defines nine CSS custom properties that form your color palette. Every style in the file references these variables instead of raw hex codes.

  • Save style.css.
  • Check your browser. The page background should now be a deep navy blue, and your heading and filter text should appear in white.

Still seeing a white background?

Make sure you saved style.css. Live Server auto-refreshes, but only after the file is saved.

Confirm that your index.html contains <link rel="stylesheet" href="style.css"> in the <head>. The filename must match exactly, including the lowercase spelling.

Why isn't my CSS loading in the browser?

Style the header and filter bar

The filter bar needs to arrange its inputs side by side, wrapping to new rows on smaller screens. Flexbox is perfect for this kind of flexible, single-direction layout.

  • Add the header and filter bar styles below your existing code in style.css:
/* Center the header text and add gold accent */
header {
    text-align: center;
    margin-bottom: 2rem;
}

header h1 {
    font-size: 2.5rem;
    color: var(--accent-gold);
    margin-bottom: 0.5rem;
}

header p {
    color: var(--text-secondary);
    font-size: 1.1rem;
}

/* Filter bar uses flexbox to wrap items horizontally */
.filters {
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
    justify-content: center;
    margin-bottom: 2rem;
    padding: 1.5rem;
    background-color: var(--bg-secondary);
    border-radius: 12px;
    border: 1px solid var(--border);
}

/* Shared styles for all filter inputs and dropdowns */
.filters input,
.filters select {
    padding: 0.75rem 1rem;
    border: 1px solid var(--border);
    border-radius: 8px;
    background-color: var(--bg-primary);
    color: var(--text-primary);
    font-size: 0.95rem;
    min-width: 180px;
}

/* Search input stretches to fill available space */
.filters input {
    flex: 1;
    min-width: 250px;
}

.filters input::placeholder {
    color: var(--text-secondary);
}

/* Gold border on focus for accessibility */
.filters select:focus,
.filters input:focus {
    outline: none;
    border-color: var(--accent-gold);
}

What does this code do?

The header gets centered text with a gold h1 using var(--accent-gold). This is your custom property in action.

The .filters section uses display: flex with flex-wrap: wrap so inputs sit side by side and wrap to new rows when space runs out. The search input has flex: 1 to grow wider than the dropdowns.

  • Save style.css and check your browser.
  • Your heading should be large and gold. Below it, the filter controls should sit inside a rounded dark panel, arranged horizontally with the search input stretching wider than the dropdowns.

Filters not appearing side by side?

Check that your index.html uses class="filters" on the <section> element wrapping your inputs. The class name in CSS must match the one in HTML exactly.

Why are my filter inputs stacking vertically instead of sitting side by side?

Create the card grid and cologne card styles

CSS Grid handles two-dimensional layouts where items need to fill rows and columns. Using auto-fill with minmax, the grid automatically creates as many columns as fit, making the layout responsive without media queries.

  • Add the card grid, cologne card, and responsive styles below your existing code in style.css:
/* Responsive grid that auto-fills columns at 350px minimum */
.cologne-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
    gap: 1.5rem;
}

/* Card container with hover animation */
.cologne-card {
    background-color: var(--bg-secondary);
    border-radius: 12px;
    border: 1px solid var(--border);
    overflow: hidden;
    transition: transform 0.2s, border-color 0.2s;
}

.cologne-card:hover {
    transform: translateY(-4px);
    border-color: var(--accent-gold);
}

/* Card header shows name, brand, and price */
.card-header {
    padding: 1.5rem;
    border-bottom: 1px solid var(--border);
}

.card-header h2 {
    font-size: 1.3rem;
    color: var(--accent-gold);
    margin-bottom: 0.25rem;
}

.card-header .brand {
    color: var(--text-secondary);
    font-size: 0.9rem;
}

.card-header .price {
    font-size: 1.5rem;
    font-weight: bold;
    color: var(--accent-red);
    margin-top: 0.5rem;
}

/* Metrics row displays longevity, projection, season, family */
.card-metrics {
    display: flex;
    gap: 1rem;
    padding: 1rem 1.5rem;
    flex-wrap: wrap;
}

.metric {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.25rem;
}

.metric-label {
    font-size: 0.75rem;
    color: var(--text-secondary);
    text-transform: uppercase;
}

.metric-value {
    font-size: 0.9rem;
    font-weight: 600;
    color: var(--text-primary);
}

/* Scent notes displayed as rounded pill tags */
.card-notes {
    padding: 0 1.5rem 1rem;
}

.card-notes h4 {
    font-size: 0.8rem;
    color: var(--text-secondary);
    text-transform: uppercase;
    margin-bottom: 0.5rem;
}

.notes-list {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
}

.note-tag {
    background-color: var(--bg-primary);
    color: var(--text-secondary);
    padding: 0.25rem 0.6rem;
    border-radius: 20px;
    font-size: 0.8rem;
}

/* Dupes section has a distinct darker background */
.dupes-section {
    background-color: var(--bg-card);
    padding: 1.5rem;
}

.dupes-section h3 {
    font-size: 0.9rem;
    color: var(--accent-gold);
    text-transform: uppercase;
    margin-bottom: 1rem;
}

.dupe-item {
    background-color: var(--bg-secondary);
    border-radius: 8px;
    padding: 1rem;
    margin-bottom: 0.75rem;
}

.dupe-item:last-child {
    margin-bottom: 0;
}

.dupe-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 0.5rem;
}

.dupe-name {
    font-weight: 600;
    font-size: 0.95rem;
}

.dupe-price {
    color: var(--accent-green);
    font-weight: bold;
}

.dupe-brand {
    color: var(--text-secondary);
    font-size: 0.85rem;
    margin-bottom: 0.5rem;
}

.dupe-metrics {
    display: flex;
    gap: 1rem;
    flex-wrap: wrap;
}

.dupe-metric {
    font-size: 0.8rem;
    color: var(--text-secondary);
}

.dupe-metric span {
    color: var(--text-primary);
    font-weight: 600;
}

.similarity-badge {
    background-color: var(--accent-red);
    color: white;
    padding: 0.2rem 0.6rem;
    border-radius: 20px;
    font-size: 0.8rem;
    font-weight: 600;
}

/* Shown when no colognes match the active filters */
.no-results {
    text-align: center;
    padding: 3rem;
    color: var(--text-secondary);
    font-size: 1.2rem;
    grid-column: 1 / -1;
}

/* Stack layout on mobile screens */
@media (max-width: 768px) {
    body {
        padding: 1rem;
    }

    header h1 {
        font-size: 1.8rem;
    }

    .cologne-grid {
        grid-template-columns: 1fr;
    }

    .filters {
        flex-direction: column;
    }

    .filters input,
    .filters select {
        min-width: 100%;
    }
}

What does this code do?

The .cologne-grid uses repeat(auto-fill, minmax(350px, 1fr)) to create as many 350px-wide columns as the screen can hold. On wide screens you get 3 columns. On narrower screens, it drops to 2 or 1 automatically.

The .cologne-card:hover rule lifts the card upward by 4 pixels and adds a gold border when the learner hovers over it. The transition property makes this movement smooth instead of instant.

The card styles are split into sections: .card-header for the cologne name and price, .card-metrics for the performance stats, .card-notes for scent profile pills, and .dupes-section for the affordable alternatives. The media query at the bottom stacks everything into a single column on screens narrower than 768px.

  • Save style.css.
  • You will not see cologne cards yet because JavaScript has not rendered them. To verify the responsive styles work, drag the edge of your browser window to make it narrow. The filter controls should stack vertically below about 768px wide.

Filters not stacking on a narrow window?

Make sure the @media block is at the very bottom of your file, after all other rules. If it appears higher up, later rules may override it.

Check for any missing closing braces } earlier in the file. A single missing brace can break every rule below it.

Why isn't my CSS media query working?

✔️ Awesome, I've got everything!

Great. Double check you have saved style.css and your page shows the dark theme with a gold heading and styled filter bar.

ⓧ I'd like to double check the full code

Here is the complete style.css file for comparison:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    --bg-primary: #1a1a2e;
    --bg-secondary: #16213e;
    --bg-card: #0f3460;
    --accent-gold: #d4a574;
    --accent-green: #4ecca3;
    --accent-red: #e94560;
    --text-primary: #ffffff;
    --text-secondary: #a8a8b3;
    --border: #2a2a4a;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background-color: var(--bg-primary);
    color: var(--text-primary);
    min-height: 100vh;
    padding: 2rem;
}

header {
    text-align: center;
    margin-bottom: 2rem;
}

header h1 {
    font-size: 2.5rem;
    color: var(--accent-gold);
    margin-bottom: 0.5rem;
}

header p {
    color: var(--text-secondary);
    font-size: 1.1rem;
}

.filters {
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
    justify-content: center;
    margin-bottom: 2rem;
    padding: 1.5rem;
    background-color: var(--bg-secondary);
    border-radius: 12px;
    border: 1px solid var(--border);
}

.filters input,
.filters select {
    padding: 0.75rem 1rem;
    border: 1px solid var(--border);
    border-radius: 8px;
    background-color: var(--bg-primary);
    color: var(--text-primary);
    font-size: 0.95rem;
    min-width: 180px;
}

.filters input {
    flex: 1;
    min-width: 250px;
}

.filters input::placeholder {
    color: var(--text-secondary);
}

.filters select:focus,
.filters input:focus {
    outline: none;
    border-color: var(--accent-gold);
}

.cologne-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
    gap: 1.5rem;
}

.cologne-card {
    background-color: var(--bg-secondary);
    border-radius: 12px;
    border: 1px solid var(--border);
    overflow: hidden;
    transition: transform 0.2s, border-color 0.2s;
}

.cologne-card:hover {
    transform: translateY(-4px);
    border-color: var(--accent-gold);
}

.card-header {
    padding: 1.5rem;
    border-bottom: 1px solid var(--border);
}

.card-header h2 {
    font-size: 1.3rem;
    color: var(--accent-gold);
    margin-bottom: 0.25rem;
}

.card-header .brand {
    color: var(--text-secondary);
    font-size: 0.9rem;
}

.card-header .price {
    font-size: 1.5rem;
    font-weight: bold;
    color: var(--accent-red);
    margin-top: 0.5rem;
}

.card-metrics {
    display: flex;
    gap: 1rem;
    padding: 1rem 1.5rem;
    flex-wrap: wrap;
}

.metric {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.25rem;
}

.metric-label {
    font-size: 0.75rem;
    color: var(--text-secondary);
    text-transform: uppercase;
}

.metric-value {
    font-size: 0.9rem;
    font-weight: 600;
    color: var(--text-primary);
}

.card-notes {
    padding: 0 1.5rem 1rem;
}

.card-notes h4 {
    font-size: 0.8rem;
    color: var(--text-secondary);
    text-transform: uppercase;
    margin-bottom: 0.5rem;
}

.notes-list {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
}

.note-tag {
    background-color: var(--bg-primary);
    color: var(--text-secondary);
    padding: 0.25rem 0.6rem;
    border-radius: 20px;
    font-size: 0.8rem;
}

.dupes-section {
    background-color: var(--bg-card);
    padding: 1.5rem;
}

.dupes-section h3 {
    font-size: 0.9rem;
    color: var(--accent-gold);
    text-transform: uppercase;
    margin-bottom: 1rem;
}

.dupe-item {
    background-color: var(--bg-secondary);
    border-radius: 8px;
    padding: 1rem;
    margin-bottom: 0.75rem;
}

.dupe-item:last-child {
    margin-bottom: 0;
}

.dupe-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 0.5rem;
}

.dupe-name {
    font-weight: 600;
    font-size: 0.95rem;
}

.dupe-price {
    color: var(--accent-green);
    font-weight: bold;
}

.dupe-brand {
    color: var(--text-secondary);
    font-size: 0.85rem;
    margin-bottom: 0.5rem;
}

.dupe-metrics {
    display: flex;
    gap: 1rem;
    flex-wrap: wrap;
}

.dupe-metric {
    font-size: 0.8rem;
    color: var(--text-secondary);
}

.dupe-metric span {
    color: var(--text-primary);
    font-weight: 600;
}

.similarity-badge {
    background-color: var(--accent-red);
    color: white;
    padding: 0.2rem 0.6rem;
    border-radius: 20px;
    font-size: 0.8rem;
    font-weight: 600;
}

.no-results {
    text-align: center;
    padding: 3rem;
    color: var(--text-secondary);
    font-size: 1.2rem;
    grid-column: 1 / -1;
}

@media (max-width: 768px) {
    body {
        padding: 1rem;
    }

    header h1 {
        font-size: 1.8rem;
    }

    .cologne-grid {
        grid-template-columns: 1fr;
    }

    .filters {
        flex-direction: column;
    }

    .filters input,
    .filters select {
        min-width: 100%;
    }
}

Your page looks polished and professional. Next up, you will bring it to life by adding cologne data in JavaScript and rendering dynamic cards into that grid.

Add Cologne Data and Render Cards

Your page looks polished, but the grid area is completely empty. The HTML and CSS are ready to display cologne cards, but there is no data and no logic to generate them.

In this step, you will write JavaScript that defines your cologne data, builds card elements from that data, and renders them into the grid. This is where your static page becomes a dynamic app.

In this step, get ready to:
  • Create a data array of 6 colognes with their dupes and performance metrics.
  • Write a function that builds a styled card element from a cologne object.
  • Render all cards into the grid so they appear in the browser.
Create the cologne data array

Every cologne your app displays needs structured data. You will store each cologne as a JavaScript object with properties for name, brand, price, performance metrics, scent notes, and an array of affordable dupes.

  • In VS Code, switch to the script.js tab.
  • Add the cologne data array by pasting this code:
const colognes = [
    {
        id: 1,
        name: "Creed Aventus",
        brand: "Creed",
        price: 445,
        longevity: 10,
        projection: "Strong",
        season: "Spring",
        scentFamily: "Fruity Chypre",
        notes: {
            top: ["Pineapple", "Bergamot", "Black Currant", "Apple"],
            middle: ["Birch", "Jasmine", "Rose"],
            base: ["Musk", "Ambergris", "Patchouli", "Vanilla"]
        },
        dupes: [
            {
                name: "Club de Nuit Intense Man EDP",
                brand: "Armaf",
                price: 35,
                similarity: 92,
                longevity: 8,
                projection: "Strong"
            },
            {
                name: "Supremacy Not Only Intense",
                brand: "Afnan",
                price: 42,
                similarity: 89,
                longevity: 9,
                projection: "Strong"
            }
        ]
    },
    {
        id: 2,
        name: "Dior Sauvage",
        brand: "Dior",
        price: 155,
        longevity: 8,
        projection: "Strong",
        season: "All Seasons",
        scentFamily: "Aromatic Fougere",
        notes: {
            top: ["Bergamot", "Pepper"],
            middle: ["Lavender", "Star Anise", "Nutmeg"],
            base: ["Ambroxan", "Cedar", "Labdanum"]
        },
        dupes: [
            {
                name: "Asad",
                brand: "Lattafa",
                price: 28,
                similarity: 85,
                longevity: 9,
                projection: "Strong"
            },
            {
                name: "Ventana Pour Homme",
                brand: "Armaf",
                price: 30,
                similarity: 88,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 3,
        name: "Baccarat Rouge 540",
        brand: "Maison Francis Kurkdjian",
        price: 325,
        longevity: 8,
        projection: "Moderate",
        season: "Fall",
        scentFamily: "Oriental Amber",
        notes: {
            top: ["Saffron", "Jasmine"],
            middle: ["Amberwood", "Ambergris"],
            base: ["Fir Resin", "Cedar"]
        },
        dupes: [
            {
                name: "Ana Abiyedh Rouge",
                brand: "Lattafa",
                price: 22,
                similarity: 88,
                longevity: 7,
                projection: "Moderate"
            },
            {
                name: "Ambre Oud",
                brand: "Nabeel",
                price: 28,
                similarity: 78,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 4,
        name: "Bleu de Chanel EDP",
        brand: "Chanel",
        price: 165,
        longevity: 8,
        projection: "Moderate",
        season: "All Seasons",
        scentFamily: "Woody Aromatic",
        notes: {
            top: ["Grapefruit", "Lemon", "Mint"],
            middle: ["Ginger", "Nutmeg", "Jasmine"],
            base: ["Incense", "Cedar", "Sandalwood"]
        },
        dupes: [
            {
                name: "Fakhar Black",
                brand: "Lattafa",
                price: 22,
                similarity: 82,
                longevity: 8,
                projection: "Moderate"
            },
            {
                name: "Tres Nuit Lyric",
                brand: "Armaf",
                price: 28,
                similarity: 82,
                longevity: 7,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 5,
        name: "Tom Ford Oud Wood",
        brand: "Tom Ford",
        price: 390,
        longevity: 7,
        projection: "Moderate",
        season: "Winter",
        scentFamily: "Woody Oud",
        notes: {
            top: ["Oud", "Rosewood", "Cardamom"],
            middle: ["Sandalwood", "Vetiver", "Tonka Bean"],
            base: ["Amber", "Musk"]
        },
        dupes: [
            {
                name: "La Yuqawam",
                brand: "Rasasi",
                price: 45,
                similarity: 85,
                longevity: 9,
                projection: "Strong"
            },
            {
                name: "Tom Oudh",
                brand: "Surrati",
                price: 15,
                similarity: 85,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 6,
        name: "Parfums de Marly Herod",
        brand: "Parfums de Marly",
        price: 330,
        longevity: 9,
        projection: "Strong",
        season: "Winter",
        scentFamily: "Spicy Woody",
        notes: {
            top: ["Cinnamon", "Pepper", "Osmanthus"],
            middle: ["Tobacco", "Incense"],
            base: ["Vanilla", "Musk", "Vetiver", "Cedar"]
        },
        dupes: [
            {
                name: "Harrod",
                brand: "Fragrenza",
                price: 70,
                similarity: 85,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    }
];

What does this data structure do?

Each cologne object holds everything a card needs to display. The notes property is a nested object with top, middle, and base arrays representing the three layers of a fragrance.

The dupes array inside each cologne holds its affordable alternatives. Each dupe has its own performance metrics and a similarity percentage showing how closely it matches the original.

You will not see a change in the browser yet. The next chunk uses this data to build visible cards.

Write the card builder function

Before you can build cards, JavaScript needs references to the HTML elements it will interact with. Then you will write a function that takes a single cologne object and returns a fully built card element.

  • Below your colognes array, add these DOM references by pasting this code:
// Get references to all filter inputs and the card grid
const searchInput = document.getElementById("search-input");
const seasonFilter = document.getElementById("season-filter");
const scentFilter = document.getElementById("scent-filter");
const projectionFilter = document.getElementById("projection-filter");
const longevityFilter = document.getElementById("longevity-filter");
const cologneGrid = document.getElementById("cologne-grid");

What does this code do?

Each document.getElementById() call finds an HTML element by its id attribute and stores a reference to it. This lets your JavaScript read values from the filter inputs and inject cards into the grid without searching the page every time.

  • Directly below the DOM references, add the createCologneCard function by pasting this code:
function createCologneCard(cologne) {
    // Create a new div element for the card
    const card = document.createElement("div");
    card.className = "cologne-card";

    // Combine all scent notes into styled tags
    const notesHtml = [...cologne.notes.top, ...cologne.notes.middle, ...cologne.notes.base]
        .map(note => `<span class="note-tag">${note}</span>`)
        .join("");

    // Build HTML for each dupe with its metrics and similarity badge
    const dupesHtml = cologne.dupes
        .map(dupe => `
            <div class="dupe-item">
                <div class="dupe-header">
                    <span class="dupe-name">${dupe.name}</span>
                    <span class="similarity-badge">${dupe.similarity}% match</span>
                </div>
                <div class="dupe-brand">${dupe.brand}</div>
                <div class="dupe-metrics">
                    <span class="dupe-metric">Price: <span class="dupe-price">$${dupe.price}</span></span>
                    <span class="dupe-metric">Lasts: <span>${dupe.longevity}h</span></span>
                    <span class="dupe-metric">Projection: <span>${dupe.projection}</span></span>
                </div>
            </div>
        `)
        .join("");

    // Assemble the full card HTML with header, metrics, notes, and dupes
    card.innerHTML = `
        <div class="card-header">
            <h2>${cologne.name}</h2>
            <div class="brand">${cologne.brand}</div>
            <div class="price">$${cologne.price}</div>
        </div>
        <div class="card-metrics">
            <div class="metric">
                <span class="metric-label">Longevity</span>
                <span class="metric-value">${cologne.longevity}h</span>
            </div>
            <div class="metric">
                <span class="metric-label">Projection</span>
                <span class="metric-value">${cologne.projection}</span>
            </div>
            <div class="metric">
                <span class="metric-label">Season</span>
                <span class="metric-value">${cologne.season}</span>
            </div>
            <div class="metric">
                <span class="metric-label">Family</span>
                <span class="metric-value">${cologne.scentFamily}</span>
            </div>
        </div>
        <div class="card-notes">
            <h4>Scent Profile</h4>
            <div class="notes-list">${notesHtml}</div>
        </div>
        <div class="dupes-section">
            <h3>Affordable Dupes</h3>
            ${dupesHtml}
        </div>
    `;

    return card;
}

What does this function do?

The function uses document.createElement("div") to make a new empty element, then fills it with HTML using template literals (the backtick strings with ${} placeholders). Template literals let you inject JavaScript values directly into HTML strings.

The spread operator ... combines the three note arrays into one flat list. The .map() method transforms each item into an HTML string, and .join("") glues them together without commas.

You will not see a change in the browser yet. The function is defined but never called. The next chunk calls it for every cologne.

Render the cards to the page

You have data and a function to build cards. Now you need a function that loops through every cologne, builds its card, and adds it to the grid.

  • Below createCologneCard, add the renderColognes function and its initial call by pasting this code:
function renderColognes() {
    // Clear any existing cards from the grid
    cologneGrid.innerHTML = "";

    // Loop through each cologne and add its card to the grid
    colognes.forEach(cologne => {
        cologneGrid.appendChild(createCologneCard(cologne));
    });
}

// Display all cards when the page first loads
renderColognes();

What does this code do?

The renderColognes function first clears the grid with innerHTML = "" so old cards do not pile up. Then .forEach() loops through every cologne in the array, calls createCologneCard to build its element, and appendChild adds it to the grid.

The final renderColognes() call at the bottom runs the function immediately when the page loads, so cards appear without any user interaction.

  • Save script.js.
  • Check your browser. You should see 6 cologne cards in a responsive grid, each showing the cologne name in gold, brand, price in red, performance metrics, scent profile tags, and an "Affordable Dupes" section with similarity badges.

Don't see any cards?

  • Open DevTools (Cmd+Option+I on macOS or Ctrl+Shift+I on Windows) and check the Console tab for red error messages. A missing comma or quote in the data array is the most common cause.
  • Make sure your script.js file is saved. Unsaved changes will not be picked up by Live Server.
  • Verify that the id values in your HTML (cologne-grid, search-input, etc.) match the strings in your getElementById calls exactly.

Help me debug why my cologne cards are not appearing in the browser.

✔️ Awesome, I've got everything!

Great. Double check you have saved script.js and all 6 cards are visible in the grid.

ⓧ I'd like to double check the full code

Here is the complete script.js file at this point. Compare it against yours:

const colognes = [
    {
        id: 1,
        name: "Creed Aventus",
        brand: "Creed",
        price: 445,
        longevity: 10,
        projection: "Strong",
        season: "Spring",
        scentFamily: "Fruity Chypre",
        notes: {
            top: ["Pineapple", "Bergamot", "Black Currant", "Apple"],
            middle: ["Birch", "Jasmine", "Rose"],
            base: ["Musk", "Ambergris", "Patchouli", "Vanilla"]
        },
        dupes: [
            {
                name: "Club de Nuit Intense Man EDP",
                brand: "Armaf",
                price: 35,
                similarity: 92,
                longevity: 8,
                projection: "Strong"
            },
            {
                name: "Supremacy Not Only Intense",
                brand: "Afnan",
                price: 42,
                similarity: 89,
                longevity: 9,
                projection: "Strong"
            }
        ]
    },
    {
        id: 2,
        name: "Dior Sauvage",
        brand: "Dior",
        price: 155,
        longevity: 8,
        projection: "Strong",
        season: "All Seasons",
        scentFamily: "Aromatic Fougere",
        notes: {
            top: ["Bergamot", "Pepper"],
            middle: ["Lavender", "Star Anise", "Nutmeg"],
            base: ["Ambroxan", "Cedar", "Labdanum"]
        },
        dupes: [
            {
                name: "Asad",
                brand: "Lattafa",
                price: 28,
                similarity: 85,
                longevity: 9,
                projection: "Strong"
            },
            {
                name: "Ventana Pour Homme",
                brand: "Armaf",
                price: 30,
                similarity: 88,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 3,
        name: "Baccarat Rouge 540",
        brand: "Maison Francis Kurkdjian",
        price: 325,
        longevity: 8,
        projection: "Moderate",
        season: "Fall",
        scentFamily: "Oriental Amber",
        notes: {
            top: ["Saffron", "Jasmine"],
            middle: ["Amberwood", "Ambergris"],
            base: ["Fir Resin", "Cedar"]
        },
        dupes: [
            {
                name: "Ana Abiyedh Rouge",
                brand: "Lattafa",
                price: 22,
                similarity: 88,
                longevity: 7,
                projection: "Moderate"
            },
            {
                name: "Ambre Oud",
                brand: "Nabeel",
                price: 28,
                similarity: 78,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 4,
        name: "Bleu de Chanel EDP",
        brand: "Chanel",
        price: 165,
        longevity: 8,
        projection: "Moderate",
        season: "All Seasons",
        scentFamily: "Woody Aromatic",
        notes: {
            top: ["Grapefruit", "Lemon", "Mint"],
            middle: ["Ginger", "Nutmeg", "Jasmine"],
            base: ["Incense", "Cedar", "Sandalwood"]
        },
        dupes: [
            {
                name: "Fakhar Black",
                brand: "Lattafa",
                price: 22,
                similarity: 82,
                longevity: 8,
                projection: "Moderate"
            },
            {
                name: "Tres Nuit Lyric",
                brand: "Armaf",
                price: 28,
                similarity: 82,
                longevity: 7,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 5,
        name: "Tom Ford Oud Wood",
        brand: "Tom Ford",
        price: 390,
        longevity: 7,
        projection: "Moderate",
        season: "Winter",
        scentFamily: "Woody Oud",
        notes: {
            top: ["Oud", "Rosewood", "Cardamom"],
            middle: ["Sandalwood", "Vetiver", "Tonka Bean"],
            base: ["Amber", "Musk"]
        },
        dupes: [
            {
                name: "La Yuqawam",
                brand: "Rasasi",
                price: 45,
                similarity: 85,
                longevity: 9,
                projection: "Strong"
            },
            {
                name: "Tom Oudh",
                brand: "Surrati",
                price: 15,
                similarity: 85,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 6,
        name: "Parfums de Marly Herod",
        brand: "Parfums de Marly",
        price: 330,
        longevity: 9,
        projection: "Strong",
        season: "Winter",
        scentFamily: "Spicy Woody",
        notes: {
            top: ["Cinnamon", "Pepper", "Osmanthus"],
            middle: ["Tobacco", "Incense"],
            base: ["Vanilla", "Musk", "Vetiver", "Cedar"]
        },
        dupes: [
            {
                name: "Harrod",
                brand: "Fragrenza",
                price: 70,
                similarity: 85,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    }
];

const searchInput = document.getElementById("search-input");
const seasonFilter = document.getElementById("season-filter");
const scentFilter = document.getElementById("scent-filter");
const projectionFilter = document.getElementById("projection-filter");
const longevityFilter = document.getElementById("longevity-filter");
const cologneGrid = document.getElementById("cologne-grid");

function createCologneCard(cologne) {
    const card = document.createElement("div");
    card.className = "cologne-card";

    const notesHtml = [...cologne.notes.top, ...cologne.notes.middle, ...cologne.notes.base]
        .map(note => `<span class="note-tag">${note}</span>`)
        .join("");

    const dupesHtml = cologne.dupes
        .map(dupe => `
            <div class="dupe-item">
                <div class="dupe-header">
                    <span class="dupe-name">${dupe.name}</span>
                    <span class="similarity-badge">${dupe.similarity}% match</span>
                </div>
                <div class="dupe-brand">${dupe.brand}</div>
                <div class="dupe-metrics">
                    <span class="dupe-metric">Price: <span class="dupe-price">$${dupe.price}</span></span>
                    <span class="dupe-metric">Lasts: <span>${dupe.longevity}h</span></span>
                    <span class="dupe-metric">Projection: <span>${dupe.projection}</span></span>
                </div>
            </div>
        `)
        .join("");

    card.innerHTML = `
        <div class="card-header">
            <h2>${cologne.name}</h2>
            <div class="brand">${cologne.brand}</div>
            <div class="price">$${cologne.price}</div>
        </div>
        <div class="card-metrics">
            <div class="metric">
                <span class="metric-label">Longevity</span>
                <span class="metric-value">${cologne.longevity}h</span>
            </div>
            <div class="metric">
                <span class="metric-label">Projection</span>
                <span class="metric-value">${cologne.projection}</span>
            </div>
            <div class="metric">
                <span class="metric-label">Season</span>
                <span class="metric-value">${cologne.season}</span>
            </div>
            <div class="metric">
                <span class="metric-label">Family</span>
                <span class="metric-value">${cologne.scentFamily}</span>
            </div>
        </div>
        <div class="card-notes">
            <h4>Scent Profile</h4>
            <div class="notes-list">${notesHtml}</div>
        </div>
        <div class="dupes-section">
            <h3>Affordable Dupes</h3>
            ${dupesHtml}
        </div>
    `;

    return card;
}

function renderColognes() {
    cologneGrid.innerHTML = "";
    colognes.forEach(cologne => {
        cologneGrid.appendChild(createCologneCard(cologne));
    });
}

renderColognes();

Your app now displays real cologne data as polished cards. Next up, you will wire up the search and filter controls so users can narrow down the results in real time.

Implement Search and Filtering

Your cologne cards look great, but right now there is no way to narrow down the results. Someone looking for a winter fragrance with strong projection has to scan all six cards manually.

In this step, you will wire up the filter controls so users can search by name and filter by season, scent family, projection, and longevity in real time.

In this step, get ready to:
  • Add filter logic to the renderColognes function using JavaScript's .filter() method.
  • Connect each filter control to the render function with event listeners.
  • Display a friendly message when no colognes match the active filters.
Add filter logic to renderColognes

Your script.js already has DOM references for each filter input from the previous step. Now you need the renderColognes function to read those values and only display colognes that match.

  • In script.js, find your existing renderColognes function. It currently looks like this:
function renderColognes() {
    cologneGrid.innerHTML = "";
    colognes.forEach(cologne => {
        cologneGrid.appendChild(createCologneCard(cologne));
    });
}
  • Replace the entire renderColognes function with this updated version that reads filter values and applies them:
function renderColognes() {
    // Read the current value of each filter input
    const searchTerm = searchInput.value.toLowerCase();
    const selectedSeason = seasonFilter.value;
    const selectedScent = scentFilter.value;
    const selectedProjection = projectionFilter.value;
    const selectedLongevity = parseInt(longevityFilter.value) || 0;

    // Keep only colognes that match ALL active filter criteria
    const filtered = colognes.filter(cologne => {
        const matchesSearch = cologne.name.toLowerCase().includes(searchTerm) ||
            cologne.brand.toLowerCase().includes(searchTerm) ||
            cologne.dupes.some(dupe =>
                dupe.name.toLowerCase().includes(searchTerm) ||
                dupe.brand.toLowerCase().includes(searchTerm)
            );

        const matchesSeason = selectedSeason === "all" ||
            cologne.season === selectedSeason ||
            cologne.season === "All Seasons";

        const matchesScent = selectedScent === "all" ||
            cologne.scentFamily === selectedScent;

        const matchesProjection = selectedProjection === "all" ||
            cologne.projection === selectedProjection;

        const matchesLongevity = cologne.longevity >= selectedLongevity;

        return matchesSearch && matchesSeason && matchesScent && matchesProjection && matchesLongevity;
    });

    // Clear the grid before re-rendering
    cologneGrid.innerHTML = "";

    // Show a message if no colognes match
    if (filtered.length === 0) {
        cologneGrid.innerHTML = `<div class="no-results">No colognes match your filters. Try adjusting your search.</div>`;
        return;
    }

    // Render only the filtered colognes
    filtered.forEach(cologne => {
        cologneGrid.appendChild(createCologneCard(cologne));
    });
}

What does this code do?

The function now reads every filter's current value before deciding which cards to show.

  • searchTerm is converted to lowercase so searching is case-insensitive. It checks the cologne name, brand, and even dupe names and brands.
  • matchesSeason has a special case: colognes marked "All Seasons" pass any season filter so they always appear.
  • parseInt(longevityFilter.value) || 0 handles the "all" option gracefully. parseInt("all") returns NaN, and || 0 converts that to 0 so every cologne passes.
  • .filter() returns a new array containing only colognes that pass ALL five checks. The original colognes array is never modified.
  • Save script.js.
  • Check your browser. All 6 cards should still display exactly as before, because no filter inputs have changed yet.

Cards disappeared or seeing an error?

  • Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). Look for a red error message pointing to a specific line number.
  • Make sure the variable names inside the function (searchInput, seasonFilter, etc.) match the DOM references you defined above the function.
  • Check that you did not accidentally delete the closing } of the function or the }); that closes the .filter() callback.

Help me debug why my cologne cards disappeared after updating renderColognes.

Connect filters with event listeners

The filter logic works, but nothing triggers it when the user types or selects an option. Event listeners tell the browser to call your function whenever a specific interaction happens.

  • In script.js, find the renderColognes(); call at the very bottom of the file. Add the following event listeners directly above it:
// Re-render cards whenever the user types or changes a filter
searchInput.addEventListener("input", renderColognes);
seasonFilter.addEventListener("change", renderColognes);
scentFilter.addEventListener("change", renderColognes);
projectionFilter.addEventListener("change", renderColognes);
longevityFilter.addEventListener("change", renderColognes);

Why "input" for search but "change" for dropdowns?

The "input" event fires on every keystroke, so results update as the user types. The "change" event fires when a dropdown selection is confirmed, which is the natural interaction for select elements.

  • Save script.js and switch to your browser.
  • Type aventus in the search box. Only the Creed Aventus card should remain.
  • Clear the search box, then select Winter from the season dropdown. You should see Tom Ford Oud Wood and Parfums de Marly Herod.
  • Now also select Strong from the projection dropdown. Only Parfums de Marly Herod should remain.
  • Type xyz in the search box. You should see the message "No colognes match your filters. Try adjusting your search."
  • Reset all filters back to their default values and clear the search box. All 6 cards should reappear.

Filters not responding?

  • Make sure the event listener lines are OUTSIDE the renderColognes function, not inside it. They should sit between the function's closing } and the final renderColognes(); call.
  • Check that you used "input" (not "change") for searchInput. Using "change" on a text input only fires when you click away, not while typing.
  • Verify there are no typos in the element IDs. The IDs in your HTML (search-input, season-filter, etc.) must match the strings in your getElementById calls.

Help me figure out why my search and filter dropdowns are not updating the cologne cards.

✔️ Awesome, I've got everything!

Great. Make sure you have saved script.js and confirmed that all filter combinations work as expected.

ⓧ I'd like to double check the full code

Here is the complete script.js at this point. Compare it against yours line by line.

const colognes = [
    {
        id: 1,
        name: "Creed Aventus",
        brand: "Creed",
        price: 445,
        longevity: 10,
        projection: "Strong",
        season: "Spring",
        scentFamily: "Fruity Chypre",
        notes: {
            top: ["Pineapple", "Bergamot", "Black Currant", "Apple"],
            middle: ["Birch", "Jasmine", "Rose"],
            base: ["Musk", "Ambergris", "Patchouli", "Vanilla"]
        },
        dupes: [
            {
                name: "Club de Nuit Intense Man EDP",
                brand: "Armaf",
                price: 35,
                similarity: 92,
                longevity: 8,
                projection: "Strong"
            },
            {
                name: "Supremacy Not Only Intense",
                brand: "Afnan",
                price: 42,
                similarity: 89,
                longevity: 9,
                projection: "Strong"
            }
        ]
    },
    {
        id: 2,
        name: "Dior Sauvage",
        brand: "Dior",
        price: 155,
        longevity: 8,
        projection: "Strong",
        season: "All Seasons",
        scentFamily: "Aromatic Fougere",
        notes: {
            top: ["Bergamot", "Pepper"],
            middle: ["Lavender", "Star Anise", "Nutmeg"],
            base: ["Ambroxan", "Cedar", "Labdanum"]
        },
        dupes: [
            {
                name: "Asad",
                brand: "Lattafa",
                price: 28,
                similarity: 85,
                longevity: 9,
                projection: "Strong"
            },
            {
                name: "Ventana Pour Homme",
                brand: "Armaf",
                price: 30,
                similarity: 88,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 3,
        name: "Baccarat Rouge 540",
        brand: "Maison Francis Kurkdjian",
        price: 325,
        longevity: 8,
        projection: "Moderate",
        season: "Fall",
        scentFamily: "Oriental Amber",
        notes: {
            top: ["Saffron", "Jasmine"],
            middle: ["Amberwood", "Ambergris"],
            base: ["Fir Resin", "Cedar"]
        },
        dupes: [
            {
                name: "Ana Abiyedh Rouge",
                brand: "Lattafa",
                price: 22,
                similarity: 88,
                longevity: 7,
                projection: "Moderate"
            },
            {
                name: "Ambre Oud",
                brand: "Nabeel",
                price: 28,
                similarity: 78,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 4,
        name: "Bleu de Chanel EDP",
        brand: "Chanel",
        price: 165,
        longevity: 8,
        projection: "Moderate",
        season: "All Seasons",
        scentFamily: "Woody Aromatic",
        notes: {
            top: ["Grapefruit", "Lemon", "Mint"],
            middle: ["Ginger", "Nutmeg", "Jasmine"],
            base: ["Incense", "Cedar", "Sandalwood"]
        },
        dupes: [
            {
                name: "Fakhar Black",
                brand: "Lattafa",
                price: 22,
                similarity: 82,
                longevity: 8,
                projection: "Moderate"
            },
            {
                name: "Tres Nuit Lyric",
                brand: "Armaf",
                price: 28,
                similarity: 82,
                longevity: 7,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 5,
        name: "Tom Ford Oud Wood",
        brand: "Tom Ford",
        price: 390,
        longevity: 7,
        projection: "Moderate",
        season: "Winter",
        scentFamily: "Woody Oud",
        notes: {
            top: ["Oud", "Rosewood", "Cardamom"],
            middle: ["Sandalwood", "Vetiver", "Tonka Bean"],
            base: ["Amber", "Musk"]
        },
        dupes: [
            {
                name: "La Yuqawam",
                brand: "Rasasi",
                price: 45,
                similarity: 85,
                longevity: 9,
                projection: "Strong"
            },
            {
                name: "Tom Oudh",
                brand: "Surrati",
                price: 15,
                similarity: 85,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    },
    {
        id: 6,
        name: "Parfums de Marly Herod",
        brand: "Parfums de Marly",
        price: 330,
        longevity: 9,
        projection: "Strong",
        season: "Winter",
        scentFamily: "Spicy Woody",
        notes: {
            top: ["Cinnamon", "Pepper", "Osmanthus"],
            middle: ["Tobacco", "Incense"],
            base: ["Vanilla", "Musk", "Vetiver", "Cedar"]
        },
        dupes: [
            {
                name: "Harrod",
                brand: "Fragrenza",
                price: 70,
                similarity: 85,
                longevity: 8,
                projection: "Moderate"
            }
        ]
    }
];

const searchInput = document.getElementById("search-input");
const seasonFilter = document.getElementById("season-filter");
const scentFilter = document.getElementById("scent-filter");
const projectionFilter = document.getElementById("projection-filter");
const longevityFilter = document.getElementById("longevity-filter");
const cologneGrid = document.getElementById("cologne-grid");

function createCologneCard(cologne) {
    const card = document.createElement("div");
    card.className = "cologne-card";

    const notesHtml = [...cologne.notes.top, ...cologne.notes.middle, ...cologne.notes.base]
        .map(note => `<span class="note-tag">${note}</span>`)
        .join("");

    const dupesHtml = cologne.dupes
        .map(dupe => `
            <div class="dupe-item">
                <div class="dupe-header">
                    <span class="dupe-name">${dupe.name}</span>
                    <span class="similarity-badge">${dupe.similarity}% match</span>
                </div>
                <div class="dupe-brand">${dupe.brand}</div>
                <div class="dupe-metrics">
                    <span class="dupe-metric">Price: <span class="dupe-price">$${dupe.price}</span></span>
                    <span class="dupe-metric">Lasts: <span>${dupe.longevity}h</span></span>
                    <span class="dupe-metric">Projection: <span>${dupe.projection}</span></span>
                </div>
            </div>
        `)
        .join("");

    card.innerHTML = `
        <div class="card-header">
            <h2>${cologne.name}</h2>
            <div class="brand">${cologne.brand}</div>
            <div class="price">$${cologne.price}</div>
        </div>
        <div class="card-metrics">
            <div class="metric">
                <span class="metric-label">Longevity</span>
                <span class="metric-value">${cologne.longevity}h</span>
            </div>
            <div class="metric">
                <span class="metric-label">Projection</span>
                <span class="metric-value">${cologne.projection}</span>
            </div>
            <div class="metric">
                <span class="metric-label">Season</span>
                <span class="metric-value">${cologne.season}</span>
            </div>
            <div class="metric">
                <span class="metric-label">Family</span>
                <span class="metric-value">${cologne.scentFamily}</span>
            </div>
        </div>
        <div class="card-notes">
            <h4>Scent Profile</h4>
            <div class="notes-list">${notesHtml}</div>
        </div>
        <div class="dupes-section">
            <h3>Affordable Dupes</h3>
            ${dupesHtml}
        </div>
    `;

    return card;
}

function renderColognes() {
    const searchTerm = searchInput.value.toLowerCase();
    const selectedSeason = seasonFilter.value;
    const selectedScent = scentFilter.value;
    const selectedProjection = projectionFilter.value;
    const selectedLongevity = parseInt(longevityFilter.value) || 0;

    const filtered = colognes.filter(cologne => {
        const matchesSearch = cologne.name.toLowerCase().includes(searchTerm) ||
            cologne.brand.toLowerCase().includes(searchTerm) ||
            cologne.dupes.some(dupe =>
                dupe.name.toLowerCase().includes(searchTerm) ||
                dupe.brand.toLowerCase().includes(searchTerm)
            );

        const matchesSeason = selectedSeason === "all" ||
            cologne.season === selectedSeason ||
            cologne.season === "All Seasons";

        const matchesScent = selectedScent === "all" ||
            cologne.scentFamily === selectedScent;

        const matchesProjection = selectedProjection === "all" ||
            cologne.projection === selectedProjection;

        const matchesLongevity = cologne.longevity >= selectedLongevity;

        return matchesSearch && matchesSeason && matchesScent && matchesProjection && matchesLongevity;
    });

    cologneGrid.innerHTML = "";

    if (filtered.length === 0) {
        cologneGrid.innerHTML = `<div class="no-results">No colognes match your filters. Try adjusting your search.</div>`;
        return;
    }

    filtered.forEach(cologne => {
        cologneGrid.appendChild(createCologneCard(cologne));
    });
}

searchInput.addEventListener("input", renderColognes);
seasonFilter.addEventListener("change", renderColognes);
scentFilter.addEventListener("change", renderColognes);
projectionFilter.addEventListener("change", renderColognes);
longevityFilter.addEventListener("change", renderColognes);

renderColognes();

Extra for Experts: Why does search also check dupe names?

A user might remember the name of the affordable alternative they heard about, not the expensive original. By searching dupe names and brands too, you surface the parent card that contains the match. This makes the tool more useful for people who come in from either direction.

Your Cologne Dupe Finder is fully functional. Users can search, filter by multiple criteria at once, and see instant feedback when nothing matches. You have built a complete interactive web app using only HTML, CSS, and JavaScript.

Secret mission

Add Sorting and Savings Display

Your dupe finder filters colognes, but users still have to scan manually for the best deal. Add a Sort By dropdown to reorder cards by price, longevity, or cheapest dupes. Then make each dupe's savings impossible to miss with a green badge showing exactly how much money they save.

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 on your Desktop or remove them.

Resources you used:

  • The cologne-dupe-finder folder on your Desktop (contains index.html, style.css, and script.js).
  • Live Server (Five Server) VS Code extension.

Keep everything running

No action needed. Choose this if you want to keep using your dupe finder or continue experimenting with the code.

  • Your project folder stays on your Desktop. Open index.html anytime to use your dupe finder.
  • The Live Server (Five Server) extension remains installed in VS Code for future projects.
  • Try adding more cologne data, integrating with a real API, or deploying to GitHub Pages for free.

Pause - I'll come back to this later

Stop the Live Server to free up the localhost port. Your files stay on your Desktop for when you return.

  • Click the Port: 5500 button in the VS Code status bar to stop Live Server.
  • Close the browser tab showing your app.
  • Your cologne-dupe-finder folder stays on your Desktop. Reopen it in VS Code and click Go Live whenever you want to continue.

Delete - I don't want to use this again

Remove all project files and the extension to start fresh.

  • Stop Live Server by clicking the Port: 5500 button in the VS Code status bar.
  • Close the browser tab showing your app.
  • Delete the project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/cologne-dupe-finder

Windows

Remove-Item -Recurse -Force ~\Desktop\cologne-dupe-finder
  • To uninstall the Live Server (Five Server) extension, open the Extensions panel in VS Code (Cmd+Shift+X on macOS or Ctrl+Shift+X on Windows).
  • Find Live Server (Five Server) in your installed extensions list, click it, then click Uninstall.

Nice Work!

Nice Work!

Nice work! You just built a fully interactive Cologne Dupe Finder web app from scratch using only HTML, CSS, and JavaScript.

You've learned how to:

  • Built a complete web application with semantic HTML structure, a dark luxury CSS theme using CSS Grid and custom properties, and dynamic JavaScript rendering.
  • Implemented real-time search and multi-field filtering using .filter(), event listeners, and DOM manipulation with createElement and template literals.
  • Designed a structured data model with nested objects and arrays representing cologne-to-dupe relationships, performance metrics, and scent profiles.
  • Secret Mission: Added a Sort By dropdown using .sort() with custom comparator functions, and displayed computed dollar savings as green badges on each dupe card.

Ready to quiz yourself?