Build a React Coffee Shop Storefront

Build and deploy a responsive React coffee shop demo with a persistent cart.

Introduction

30 Second Summary

Choosing from a coffee shop menu should feel quick. Crowded choices on a small screen can turn that simple decision into a hunt.

In this project, you will build Bean & Brew, a fictional coffee shop storefront with React. The finished demo combines menu discovery with a refresh-safe cart before going live on GitHub Pages.

What You'll Build

You will open Bean & Brew on a phone or desktop to search the menu, build a demo order, refresh the page, and find your cart still waiting.

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

  • A searchable menu where visitors can find drinks by name or switch between coffee, tea, and pastry categories.
  • A refresh-safe cart where visitors can add products, change quantities, remove items, and see the running total.
  • A public educational demo on GitHub Pages that confirms demo orders without collecting personal or payment data.
  • Secret Mission: Add a seasonal menu category with two new products without changing the storefront component structure.

Are there any prerequisites?

You need basic HTML, CSS, and JavaScript knowledge plus Visual Studio Code and a GitHub account.

The first step checks Node.js and Git on Windows, so you can set up either tool there if needed.

Before We Start

First, lock in the experience you are about to create. Bean & Brew is an educational storefront where visitors can browse, search, filter, and create a demo order without sharing personal or payment data.

Set Up the React Project

Bean & Brew needs a reproducible local runtime before interface work can produce a visible result. This step turns the project plan into a starter page running on your computer.

You'll use Visual Studio Code to manage the files. React supplies the component structure. Vite runs the development server.

In this step, get ready to:
  • Verify Git for Windows.
  • Verify Node.js plus npm.
  • Run the Bean & Brew starter through Vite.
Verify the Windows toolchain

Git records future changes to the storefront. Node.js runs the JavaScript tools that install React dependencies and start Vite.

Why Vite for this storefront?

This release runs entirely in the browser. Vite keeps the setup focused on frontend interactions.

Next.js includes server features that this educational prototype does not use. Vite gives you a smaller local build with a direct path to static deployment.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Press Ctrl+` to open the integrated PowerShell terminal.
  • Check the installed Git version by running this command:
git --version

What does this command check?

The command asks Git to print its installed version. The result tells you whether the required Windows build is available.

✔️ I see the required Git version

You should see git version 2.55.0.windows.5. Git is ready for the repository work later in the project.

ⓧ I see an older Git version

The older installation needs an upgrade before you publish the storefront.

  • Open the official Git for Windows installer page.
  • Download the maintained x64 build for version 2.55.0(5).
  • Run the downloaded installer.
  • Keep the installer defaults through the setup.
  • Restart Visual Studio Code after the installation finishes.
  • Open its PowerShell terminal again.
  • Run the Git version check shown above again.

Still seeing the older version?

Close every Visual Studio Code window before reopening the app. This gives the terminal a fresh view of your installed tools.

Still stuck? Help me verify my Git for Windows installation.

ⓧ Git is not recognized

Windows cannot find Git yet. Install the required build before continuing.

  • Open the official Git for Windows installer page.
  • Download the maintained x64 build for version 2.55.0(5).
  • Run the downloaded installer.
  • Keep the installer defaults through the setup.
  • Restart Visual Studio Code after the installation finishes.
  • Open its PowerShell terminal again.
  • Run the Git version check shown above again.

Git still cannot be found?

Confirm that the installer completed before reopening Visual Studio Code. A terminal opened before installation may not detect Git.

Still stuck? Help me make Git available in PowerShell.

Node.js v24.21.0 LTS satisfies Vite's supported Node.js range. The Windows installer also provides npm for dependency installation.

  • Check the installed Node.js version plus the npm version by running these commands:
node -v
npm -v

What do these checks prove?

  • The first command prints the installed Node.js version.
  • The second command confirms that npm is available.

✔️ I see the required Node.js version

The first line should show v24.21.0. The second line should contain an npm version number.

Your JavaScript toolchain is ready. You can now install the exact packages declared by Bean & Brew.

ⓧ I see an older Node.js version

Upgrade Node.js before installing the project dependencies. The required release is v24.21.0 LTS.

  • Download the official Node.js Windows x64 MSI.
  • Run the downloaded installer.
  • Keep the installer defaults through the setup.
  • Restart Visual Studio Code after the installation finishes.
  • Open its PowerShell terminal again.
  • Run both version checks shown above again.

Still seeing the older Node.js version?

Close every Visual Studio Code window before checking again. The reopened terminal should use the updated Node.js installation.

Still stuck? Help me verify which Node.js installation PowerShell is using.

ⓧ Node.js is not recognized

Install Node.js before creating the project. The installer includes npm.

  • Download the official Node.js Windows x64 MSI.
  • Run the downloaded installer.
  • Keep the installer defaults through the setup.
  • Restart Visual Studio Code after the installation finishes.
  • Open its PowerShell terminal again.
  • Run both version checks shown above again.

Node.js still cannot be found?

Confirm that the MSI finished successfully before reopening Visual Studio Code. A terminal left open during installation may not detect Node.js.

Still stuck? Help me make Node.js available in PowerShell.

Create the project files

The starter needs a manifest, an HTML document, a Vite configuration, and three source files. Each file has one clear role in the startup path.

  • Open the File menu in Visual Studio Code.
  • Choose the folder-opening option.
  • Create a folder named bean-and-brew on your Desktop through the Windows folder picker.
  • Select the bean-and-brew folder as the current workspace.

You should see bean-and-brew at the top of the Explorer sidebar. This confirms that every new file lands in the correct folder.

The package.json manifest pins every direct dependency. Matching versions keep the local build reproducible.

  • Create package.json inside bean-and-brew by selecting the new-file icon in the Explorer sidebar.
  • Paste this manifest into package.json:
{
  "name": "bean-and-brew",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.1",
    "vite": "8.3.0"
  }
}

What does this manifest define?

  • The scripts connect npm to Vite's development, build, and preview commands.
  • The dependencies pin React plus React DOM to 19.3.0.
  • The development dependencies pin Vite to 8.3.0 plus its React plugin to 6.1.1.
  • Save package.json.
  • Confirm that package.json remains visible beneath bean-and-brew in the Explorer sidebar.

Does the manifest show an error?

Check every comma plus quotation mark against the reference. JSON rejects missing commas and trailing commas.

Still stuck? Help me find the JSON syntax problem.

The browser loads index.html first. Its root element gives React one place to render the storefront.

  • Create index.html inside bean-and-brew from the Explorer sidebar.
  • Paste this document into index.html:
<!doctype html>
<html lang="vi">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Bean & Brew là bản mẫu giáo dục cho giao diện website coffee shop." />
    <title>Bean & Brew | Coffee Shop Demo</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

How does the page reach React?

The root element is the mounting point for the interface. The module script loads src/main.jsx as the JavaScript entry.

  • Save index.html.
  • Confirm that the editor preserves the root element plus the /src/main.jsx module path.

Is the HTML structure highlighted incorrectly?

Check that every opening tag has its matching closing tag. Confirm that the file name ends in .html.

Still stuck? Help me check my Bean & Brew HTML document.

The Vite configuration activates React support. Its base path also matches the repository name used by the published project.

  • Create vite.config.js inside bean-and-brew from the Explorer sidebar.
  • Paste this configuration into vite.config.js:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  base: "/bean-and-brew/",
  plugins: [react()],
});

What does this configuration do?

  • The imports load Vite's configuration helper plus the React plugin.
  • The base value matches the future GitHub Pages repository path.
  • The plugin enables React files inside Vite.
  • Save vite.config.js.
  • Confirm that vite.config.js appears beside package.json in the Explorer sidebar.

Is the configuration marked as invalid?

Check the braces plus brackets around defineConfig and react(). Confirm that the file is named vite.config.js.

Still stuck? Help me check my Vite configuration.

The src folder holds the React entry, the starter component, and the stylesheet. Vite follows this chain when the browser requests the page.

  • Create a folder named src inside bean-and-brew by selecting the new-folder icon in the Explorer sidebar.

You should see an empty src folder beneath the three top-level files. The source files now have a clear destination.

  • Create main.jsx inside the src folder.
  • Paste this entry into src/main.jsx:
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";

createRoot(document.getElementById("root")).render(<App />);

How does the React entry work?

The imports load React DOM's createRoot function, the App component, and the stylesheet.

The final line mounts App inside the HTML element named root.

  • Save src/main.jsx.
  • Confirm that main.jsx appears inside src in the Explorer sidebar.

Does the entry show unresolved imports?

The local files named in the imports do not exist until you create the next two files. Check that the paths begin with ./.

Still stuck? Help me check the React entry imports.

  • Create App.jsx inside the src folder.
  • Paste this starter component into src/App.jsx:
export default function App() {
  return (
    <main id="top">
      <h1 id="hero-title">Bean & Brew</h1>
    </main>
  );
}

What does this component render?

The default App component returns the first visible storefront screen. Its heading gives the development server a concrete result to display.

  • Save src/App.jsx.
  • Confirm that the component contains the Bean & Brew heading.

Does the component show a syntax error?

Check the closing parenthesis plus closing brace at the bottom of App.jsx. Confirm that the opening and closing main tags match.

Still stuck? Help me check the starter component.

  • Create index.css inside the src folder.
  • Paste this starter visual system into src/index.css:
:root {
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #2b1d16;
  background: #f7f1e8;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background: radial-gradient(circle at top right, rgba(210, 156, 92, 0.2), transparent 28rem), #f7f1e8;
}

What does this stylesheet establish?

The root rules set the typeface plus warm foreground and background colours. The body rules remove the default margin and make the page fill the browser height.

The universal box-sizing rule makes future element dimensions include their borders and padding. This keeps responsive sizing predictable.

  • Save src/index.css.
  • Confirm that App.jsx, index.css, and main.jsx appear inside src.

Is the stylesheet not recognized?

Confirm that the file is named index.css inside src. Check that main.jsx imports the matching relative path.

Still stuck? Help me connect the starter stylesheet.

✔️ Awesome, I've got everything!

Great. Double check that all six files are saved before installing the dependencies.

ⓧ I'd like to double check the full code

Compare each saved file with these complete starter references.

{
  "name": "bean-and-brew",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.1",
    "vite": "8.3.0"
  }
}
<!doctype html>
<html lang="vi">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Bean & Brew là bản mẫu giáo dục cho giao diện website coffee shop." />
    <title>Bean & Brew | Coffee Shop Demo</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  base: "/bean-and-brew/",
  plugins: [react()],
});
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";

createRoot(document.getElementById("root")).render(<App />);
export default function App() {
  return (
    <main id="top">
      <h1 id="hero-title">Bean & Brew</h1>
    </main>
  );
}
:root {
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #2b1d16;
  background: #f7f1e8;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background: radial-gradient(circle at top right, rgba(210, 156, 92, 0.2), transparent 28rem), #f7f1e8;
}
Install dependencies and launch the starter

The source files now describe the startup path. npm must turn the pinned manifest into installed packages before Vite can run it.

  • Return to the Visual Studio Code PowerShell terminal from earlier.
  • Install the pinned project dependencies by running:
npm install

What does this installation create?

npm reads package.json and installs the declared packages into node_modules.

It also generates package-lock.json to record the resolved dependency tree.

  • Confirm that node_modules appears in the Explorer sidebar.
  • Confirm that package-lock.json appears beside package.json.

That is the dependency layer in place. Bean & Brew now has every package required to start locally.

Did the dependency installation fail?

Confirm that the terminal is attached to the open bean-and-brew workspace. Check that package.json is saved before trying again.

Still stuck? Help me diagnose my npm installation.

Before you start the server, what do you expect the browser to show when React mounts the App component?

  • Start the Vite development server by running:
npm run dev

What happens while this command runs?

npm runs the manifest's development script. Vite keeps the terminal occupied while it serves the project.

Keep this terminal running during the build. Saved source changes can then reach the browser through the development server.

  • Visit http://localhost:5173 in your browser.

You'll see the Bean & Brew heading on a warm background. This proves that Vite loaded the HTML entry and React mounted the starter component.

You have the first visible win: Bean & Brew is running locally through the pinned React toolchain.

Does the starter page fail to load?

Keep the terminal running while you visit the local URL. Confirm that index.html points to /src/main.jsx.

Check that src/main.jsx imports both ./App.jsx and ./index.css.

Still stuck? Help me debug the Bean & Brew starter page.

Your local runtime is ready. Next, you'll replace the starter heading with the searchable Bean & Brew menu.

Build the Searchable Menu

Your React starter confirms that the local project can render in the browser. Bean & Brew now needs a menu that visitors can browse.

A visitor needs a quick way to discover products before a cart has any value. In this step, you will turn the starter screen into a responsive storefront with controlled search and category filtering.

In this step, get ready to:
  • Create structured data for six menu products.
  • Build a controlled search field with category filters.
  • Style the storefront for narrow screens and desktop layouts.
Create the menu data

The interface can render every product from one shared array. This keeps product details separate from the component that displays them.

  • In the Visual Studio Code Explorer sidebar, select the src folder.
  • Create data.js inside the src folder.
  • Add the categories and six products by pasting this code:
export const CATEGORIES = ["Tất cả", "Cà phê", "Trà", "Bánh ngọt"];

export const PRODUCTS = [
  { id: 1, name: "Espresso", category: "Cà phê", description: "Đậm vị, cân bằng và có lớp crema mịn.", price: 35000, art: "ES" },
  { id: 2, name: "Cà phê sữa đá", category: "Cà phê", description: "Cà phê rang đậm kết hợp sữa đặc và đá lạnh.", price: 39000, art: "SD" },
  { id: 3, name: "Vanilla Latte", category: "Cà phê", description: "Espresso, sữa tươi và vanilla dịu nhẹ.", price: 52000, art: "VL" },
  { id: 4, name: "Trà đào cam sả", category: "Trà", description: "Trà trái cây thanh mát với đào, cam và sả.", price: 48000, art: "TD" },
  { id: 5, name: "Croissant bơ", category: "Bánh ngọt", description: "Bánh nhiều lớp, thơm bơ và nướng giòn mỗi ngày.", price: 42000, art: "CB" },
  { id: 6, name: "Tiramisu", category: "Bánh ngọt", description: "Kem mascarpone mềm mịn cùng hương cà phê.", price: 55000, art: "TI" },
];

How Is the Menu Structured?

  • The CATEGORIES array supplies the labels for the filter buttons.
  • Each object in PRODUCTS describes one product card.
  • Numeric price values make later cart calculations possible.
  • Unique id values help React track each rendered product.
  • Save src/data.js.
  • Confirm that data.js appears beneath the src folder in the Explorer sidebar.

Is the Data File in the Wrong Place?

Check that the path is src/data.js. A file beside the src folder cannot be reached through the import used in this step.

Still stuck? Help me check where src/data.js belongs in my React project.

✔️ Awesome, I've got everything!

Great. Your menu now has one data source for its categories and products.

ⓧ I'd like to double check the full code

export const CATEGORIES = ["Tất cả", "Cà phê", "Trà", "Bánh ngọt"];

export const PRODUCTS = [
  { id: 1, name: "Espresso", category: "Cà phê", description: "Đậm vị, cân bằng và có lớp crema mịn.", price: 35000, art: "ES" },
  { id: 2, name: "Cà phê sữa đá", category: "Cà phê", description: "Cà phê rang đậm kết hợp sữa đặc và đá lạnh.", price: 39000, art: "SD" },
  { id: 3, name: "Vanilla Latte", category: "Cà phê", description: "Espresso, sữa tươi và vanilla dịu nhẹ.", price: 52000, art: "VL" },
  { id: 4, name: "Trà đào cam sả", category: "Trà", description: "Trà trái cây thanh mát với đào, cam và sả.", price: 48000, art: "TD" },
  { id: 5, name: "Croissant bơ", category: "Bánh ngọt", description: "Bánh nhiều lớp, thơm bơ và nướng giòn mỗi ngày.", price: 42000, art: "CB" },
  { id: 6, name: "Tiramisu", category: "Bánh ngọt", description: "Kem mascarpone mềm mịn cùng hương cà phê.", price: 55000, art: "TI" },
];
Build the searchable storefront

A controlled search field stores its current value in component state. React can use that value immediately to derive the visible products.

Why React with Vite?

This storefront runs entirely in the browser. Vite keeps the local feedback loop quick while React manages the changing search and category state.

A server-rendered framework would add backend concepts that this interactive prototype does not need.

  • Switch back to src/App.jsx from earlier.
  • Select the starter component in src/App.jsx.
  • Delete the selected starter component.
  • Start the new component by pasting this first section:
import { useState } from "react";
import { CATEGORIES, PRODUCTS } from "./data.js";

function formatPrice(value) {
  return `${String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ".")}đ`;
}

export default function App() {
  const [selectedCategory, setSelectedCategory] = useState("Tất cả");
  const [query, setQuery] = useState("");

  function addToCart(product) {}

  const normalizedQuery = query.trim().toLowerCase();
  const filteredProducts = PRODUCTS.filter((product) => {
    const matchesCategory = selectedCategory === "Tất cả" || product.category === selectedCategory;
    const searchableText = `${product.name} ${product.description}`.toLowerCase();
    return matchesCategory && searchableText.includes(normalizedQuery);
  });

What Does the Storefront Logic Do?

  • The selectedCategory state records the active filter.
  • The query state records the search input.
  • The filteredProducts array contains only products that satisfy both controls.
  • The temporary addToCart handler keeps the product buttons safe to click until the cart is built.
  • Continue below the filtering logic by pasting the disclaimer and header:
  return (
    <>
      <div className="demo-banner" role="note">
        Bản mẫu giáo dục. Website không nhận đơn thật, thông tin cá nhân hoặc thanh toán.
      </div>

      <header className="site-header">
        <a className="brand" href="#top" aria-label="Bean & Brew, về đầu trang">
          <span className="brand-mark">B&B</span>
          <span>Bean & Brew</span>
        </a>
        <nav aria-label="Điều hướng chính">
          <a href="#menu">Menu</a>
          <a href="#visit">Ghé quán</a>
        </nav>
      </header>

What Does the Header Establish?

The disclaimer makes the educational boundary visible before a visitor reaches the menu. The labeled navigation gives keyboard and assistive technology users clear destinations.

  • Continue below the header by pasting the hero and store information:
      <main id="top">
        <section className="hero" aria-labelledby="hero-title">
          <div className="hero-copy">
            <p className="eyebrow">Rang mới mỗi ngày</p>
            <h1 id="hero-title">Một khoảng nghỉ thơm mùi cà phê.</h1>
            <p>Khám phá đồ uống cân bằng, bánh nướng trong ngày và một trải nghiệm đặt món demo thật gọn trên mọi màn hình.</p>
            <a className="primary-link" href="#menu">Xem menu</a>
          </div>
          <div className="hero-card" aria-label="Thông tin nổi bật">
            <span className="hero-cup">B&B</span>
            <strong>Hạt rang vừa</strong>
            <p>Hương cacao, caramel và hạt rang.</p>
          </div>
        </section>

        <section className="store-strip" id="visit" aria-label="Thông tin cửa hàng">
          <div><span>Giờ mở cửa</span><strong>07:00 đến 21:00</strong></div>
          <div><span>Địa chỉ demo</span><strong>25 Đường Hạt Nắng</strong></div>
          <div><span>Hình thức</span><strong>Dùng tại quán và mang đi</strong></div>
        </section>

What Does This Section Add?

The hero gives the fictional shop a clear identity. The store strip presents opening hours and location details as demo content.

  • Continue below the store information by pasting the menu heading and search field:
        <section className="menu-section" id="menu" aria-labelledby="menu-title">
          <div className="section-heading">
            <div><p className="eyebrow">Menu chọn lọc</p><h2 id="menu-title">Chọn món bạn thích</h2></div>
            <label className="search-field" htmlFor="menu-search">
              <span>Tìm trong menu</span>
              <input id="menu-search" type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Ví dụ: latte" />
            </label>
          </div>

          <div className="category-list" aria-label="Lọc theo danh mục">
            {CATEGORIES.map((category) => (
              <button key={category} className={selectedCategory === category ? "active" : ""} aria-pressed={selectedCategory === category} onClick={() => setSelectedCategory(category)}>
                {category}
              </button>
            ))}
          </div>

          <p className="result-count" aria-live="polite">Hiển thị {filteredProducts.length} món</p>

How Do the Controls Stay in Sync?

  • The search input reads from query and updates it whenever the visitor types.
  • Each category button updates selectedCategory.
  • The aria-pressed state communicates the active category to assistive technology.
  • The live result count announces how many products remain visible.
  • Finish the component by pasting the product grid and footer:
          <div className="product-grid">
            {filteredProducts.map((product) => (
              <article className="product-card" key={product.id}>
                <div className="product-art" aria-hidden="true">{product.art}</div>
                <div className="product-content">
                  <span className="product-category">{product.category}</span>
                  <h3>{product.name}</h3>
                  <p>{product.description}</p>
                  <div className="product-footer">
                    <strong>{formatPrice(product.price)}</strong>
                    <button onClick={() => addToCart(product)}>Thêm vào giỏ</button>
                  </div>
                </div>
              </article>
            ))}
          </div>

          {filteredProducts.length === 0 && <p className="empty-results">Chưa có món phù hợp. Hãy thử từ khóa hoặc danh mục khác.</p>}
        </section>

        <p className="order-message" aria-live="polite"></p>
      </main>

      <footer>
        <p>Bean & Brew</p>
        <p>Dự án học tập độc lập. Không đại diện cho một cửa hàng có thật.</p>
      </footer>
    </>
  );
}

How Does the Grid Render Products?

  • The grid maps every item in filteredProducts to one product card.
  • The formatPrice helper turns numeric prices into readable Vietnamese đồng values.
  • The empty-results message appears only when no product matches the current controls.
  • The empty order-message region reserves an accessible announcement point for a later step.
  • Save src/App.jsx.
  • Return to the browser tab showing http://localhost:5173.

You should see the educational disclaimer, navigation, hero, store details, search field, category buttons, and six unstyled product records.

Is the Storefront Missing?

  • Check that the data import uses ./data.js.
  • Confirm that every opening JSX element has a matching closing element.
  • Check the Visual Studio Code terminal for the first file and line number reported by Vite.

Still stuck? Help me debug my searchable App.jsx component.

✔️ Awesome, I've got everything!

Your browser now shows the complete storefront structure. The styling comes next.

ⓧ I'd like to double check the full code

import { useState } from "react";
import { CATEGORIES, PRODUCTS } from "./data.js";

function formatPrice(value) {
  return `${String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ".")}đ`;
}

export default function App() {
  const [selectedCategory, setSelectedCategory] = useState("Tất cả");
  const [query, setQuery] = useState("");

  function addToCart(product) {}

  const normalizedQuery = query.trim().toLowerCase();
  const filteredProducts = PRODUCTS.filter((product) => {
    const matchesCategory = selectedCategory === "Tất cả" || product.category === selectedCategory;
    const searchableText = `${product.name} ${product.description}`.toLowerCase();
    return matchesCategory && searchableText.includes(normalizedQuery);
  });

  return (
    <>
      <div className="demo-banner" role="note">
        Bản mẫu giáo dục. Website không nhận đơn thật, thông tin cá nhân hoặc thanh toán.
      </div>

      <header className="site-header">
        <a className="brand" href="#top" aria-label="Bean & Brew, về đầu trang">
          <span className="brand-mark">B&B</span>
          <span>Bean & Brew</span>
        </a>
        <nav aria-label="Điều hướng chính">
          <a href="#menu">Menu</a>
          <a href="#visit">Ghé quán</a>
        </nav>
      </header>

      <main id="top">
        <section className="hero" aria-labelledby="hero-title">
          <div className="hero-copy">
            <p className="eyebrow">Rang mới mỗi ngày</p>
            <h1 id="hero-title">Một khoảng nghỉ thơm mùi cà phê.</h1>
            <p>Khám phá đồ uống cân bằng, bánh nướng trong ngày và một trải nghiệm đặt món demo thật gọn trên mọi màn hình.</p>
            <a className="primary-link" href="#menu">Xem menu</a>
          </div>
          <div className="hero-card" aria-label="Thông tin nổi bật">
            <span className="hero-cup">B&B</span>
            <strong>Hạt rang vừa</strong>
            <p>Hương cacao, caramel và hạt rang.</p>
          </div>
        </section>

        <section className="store-strip" id="visit" aria-label="Thông tin cửa hàng">
          <div><span>Giờ mở cửa</span><strong>07:00 đến 21:00</strong></div>
          <div><span>Địa chỉ demo</span><strong>25 Đường Hạt Nắng</strong></div>
          <div><span>Hình thức</span><strong>Dùng tại quán và mang đi</strong></div>
        </section>

        <section className="menu-section" id="menu" aria-labelledby="menu-title">
          <div className="section-heading">
            <div><p className="eyebrow">Menu chọn lọc</p><h2 id="menu-title">Chọn món bạn thích</h2></div>
            <label className="search-field" htmlFor="menu-search">
              <span>Tìm trong menu</span>
              <input id="menu-search" type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Ví dụ: latte" />
            </label>
          </div>

          <div className="category-list" aria-label="Lọc theo danh mục">
            {CATEGORIES.map((category) => (
              <button key={category} className={selectedCategory === category ? "active" : ""} aria-pressed={selectedCategory === category} onClick={() => setSelectedCategory(category)}>
                {category}
              </button>
            ))}
          </div>

          <p className="result-count" aria-live="polite">Hiển thị {filteredProducts.length} món</p>

          <div className="product-grid">
            {filteredProducts.map((product) => (
              <article className="product-card" key={product.id}>
                <div className="product-art" aria-hidden="true">{product.art}</div>
                <div className="product-content">
                  <span className="product-category">{product.category}</span>
                  <h3>{product.name}</h3>
                  <p>{product.description}</p>
                  <div className="product-footer">
                    <strong>{formatPrice(product.price)}</strong>
                    <button onClick={() => addToCart(product)}>Thêm vào giỏ</button>
                  </div>
                </div>
              </article>
            ))}
          </div>

          {filteredProducts.length === 0 && <p className="empty-results">Chưa có món phù hợp. Hãy thử từ khóa hoặc danh mục khác.</p>}
        </section>

        <p className="order-message" aria-live="polite"></p>
      </main>

      <footer>
        <p>Bean & Brew</p>
        <p>Dự án học tập độc lập. Không đại diện cho một cửa hàng có thật.</p>
      </footer>
    </>
  );
}
Style and test the menu

A mobile-first stylesheet gives the narrowest screens a usable default layout. Wider layouts then expand the hero and product grid through responsive breakpoints.

  • Switch back to src/index.css from earlier.
  • Select the existing stylesheet content.
  • Delete the selected content.
  • Add the global styles and sticky header by pasting this section:
:root {
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #2b1d16;
  background: #f7f1e8;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background: radial-gradient(circle at top right, rgba(210, 156, 92, 0.2), transparent 28rem), #f7f1e8;
}
button, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid #d28a3c; outline-offset: 3px; }
button { cursor: pointer; }
.demo-banner { padding: 0.65rem 1rem; color: #fff8ed; background: #5c2f1c; text-align: center; font-size: 0.9rem; font-weight: 700; }
.site-header { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 5vw, 5rem); background: rgba(247, 241, 232, 0.94); border-bottom: 1px solid rgba(74, 45, 32, 0.12); backdrop-filter: blur(12px); }

What Do the Global Styles Control?

These rules establish the warm page colors and consistent element sizing. The visible focus outline keeps interactive controls clear during keyboard navigation.

  • Save src/index.css.
  • Return to the browser.

You should see a cream page background, a dark educational banner, and a sticky header.

  • Continue below the header styles by pasting the brand and hero styles:
.brand { display: inline-flex; align-items: center; gap: 0.7rem; color: #2b1d16; text-decoration: none; font-weight: 900; }
.brand-mark { display: grid; width: 2.5rem; height: 2.5rem; place-items: center; color: #fff8ed; background: #6f3d26; border-radius: 50%; font-size: 0.78rem; letter-spacing: 0.06em; }
.site-header nav { display: flex; align-items: center; gap: 1rem; }
.site-header nav a { display: none; color: #4d3428; text-decoration: none; font-weight: 700; }
.cart-button, .product-footer button, .checkout-button { border: 0; border-radius: 999px; color: #fff; background: #6f3d26; font-weight: 800; }
.cart-button { padding: 0.75rem 1rem; }
.cart-button span { display: inline-grid; min-width: 1.5rem; min-height: 1.5rem; margin-left: 0.35rem; place-items: center; color: #6f3d26; background: #fff8ed; border-radius: 50%; }
main { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; }
.hero { display: grid; gap: 2rem; padding: clamp(3rem, 8vw, 7rem) 0 3rem; }
.eyebrow { margin: 0 0 0.5rem; color: #9b542d; font-size: 0.78rem; font-weight: 900; letter-spacing: 0.14em; text-transform: uppercase; }
.hero h1, .section-heading h2, .cart-heading h2 { margin: 0; font-family: Georgia, "Times New Roman", serif; line-height: 1.02; }
.hero h1 { max-width: 11ch; font-size: clamp(3rem, 9vw, 6.6rem); }
.hero-copy > p:not(.eyebrow) { max-width: 39rem; color: #6c5144; font-size: 1.08rem; line-height: 1.75; }
.primary-link { display: inline-flex; margin-top: 0.5rem; padding: 0.9rem 1.25rem; color: #fff; background: #6f3d26; border-radius: 999px; text-decoration: none; font-weight: 800; }
.hero-card { display: grid; min-height: 20rem; padding: 2rem; place-content: end start; color: #fff8ed; background: linear-gradient(145deg, rgba(255,255,255,0.08), transparent), #3a2118; border-radius: 2rem; box-shadow: 0 1.5rem 4rem rgba(70,36,22,0.22); }
.hero-cup { display: grid; width: 7rem; height: 7rem; margin-bottom: 3rem; place-items: center; border: 2px solid #d8a064; border-radius: 50% 50% 42% 42%; color: #d8a064; font-family: Georgia, "Times New Roman", serif; font-size: 1.5rem; font-weight: 900; letter-spacing: 0.08em; }
.hero-card strong { font-size: 1.35rem; }
.hero-card p { margin-bottom: 0; color: #e3d2c2; }

How Does the Hero Get Its Visual Weight?

Fluid spacing and type sizes let the hero adapt without fixed screen assumptions. The dark feature card creates contrast against the cream page.

  • Save src/index.css.
  • Return to the browser.

You should see the large coffee shop heading beside a dark Bean & Brew feature card on a wide screen.

  • Continue below the hero styles by pasting the store and menu controls:
.store-strip { display: grid; gap: 1rem; padding: 1.25rem; background: #fffaf2; border: 1px solid rgba(74,45,32,0.12); border-radius: 1.25rem; }
.store-strip div { display: grid; gap: 0.2rem; }
.store-strip span { color: #8b6b5b; font-size: 0.82rem; }
.menu-section { padding: 5rem 0; }
.section-heading { display: grid; gap: 1.5rem; margin-bottom: 1.5rem; }
.section-heading h2, .cart-heading h2 { font-size: clamp(2.3rem, 6vw, 4rem); }
.search-field { display: grid; gap: 0.45rem; font-size: 0.86rem; font-weight: 800; }
.search-field input { width: 100%; padding: 0.9rem 1rem; color: #2b1d16; background: #fff; border: 1px solid #cdb9aa; border-radius: 0.9rem; }
.category-list { display: flex; gap: 0.6rem; overflow-x: auto; padding: 0.25rem 0 0.75rem; }
.category-list button { flex: 0 0 auto; padding: 0.72rem 1rem; color: #5c3d2e; background: transparent; border: 1px solid #b99c89; border-radius: 999px; font-weight: 800; }
.category-list button.active { color: #fff; background: #6f3d26; border-color: #6f3d26; }
.result-count { color: #806252; font-size: 0.9rem; }

How Do the Filters Stay Usable?

The category row can scroll horizontally on narrow screens. The active category uses stronger contrast to match its pressed state.

  • Save src/index.css.
  • Press the Tab key until the search field receives focus.

You should see a visible amber focus outline around the search field.

  • Continue below the result count by pasting the product and footer styles:
.product-grid { display: grid; gap: 1rem; }
.product-card { overflow: hidden; background: #fffaf2; border: 1px solid rgba(74,45,32,0.12); border-radius: 1.4rem; }
.product-art { display: grid; min-height: 11rem; place-items: center; color: #f8e9d7; background: radial-gradient(circle at 65% 25%, #c58550, transparent 28%), linear-gradient(135deg, #8b4f31, #3b2117); font-family: Georgia, "Times New Roman", serif; font-size: 2.4rem; font-weight: 900; letter-spacing: 0.08em; }
.product-content { padding: 1.2rem; }
.product-category { color: #9b542d; font-size: 0.78rem; font-weight: 900; text-transform: uppercase; }
.product-content h3 { margin: 0.45rem 0; font-size: 1.25rem; }
.product-content p { min-height: 3rem; margin: 0; color: #73594c; line-height: 1.55; }
.product-footer { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; margin-top: 1.2rem; }
.product-footer button { padding: 0.75rem 0.95rem; }
.empty-results, .empty-cart { padding: 2rem; color: #73594c; background: #fffaf2; border-radius: 1rem; text-align: center; }
.order-message { min-height: 1.5rem; margin: -3rem 0 3rem; color: #2d6a43; font-weight: 800; text-align: center; }
footer { display: grid; gap: 0.5rem; padding: 2rem 1rem; color: #ead8c8; background: #2f1b14; text-align: center; }
footer p { margin: 0; }

What Gives Each Product Its Card Layout?

Each product receives a dark art panel and a light content area. The footer keeps the formatted price beside the future cart action.

  • Save src/index.css.
  • Return to the browser.

You should see six rounded product cards with initials, descriptions, prices, and brown action buttons.

  • Finish the stylesheet by pasting the responsive layout rules:
@media (min-width: 700px) {
  .site-header nav a { display: inline; }
  .hero { grid-template-columns: 1.25fr 0.75fr; align-items: center; }
  .store-strip { grid-template-columns: repeat(3, 1fr); }
  .section-heading { grid-template-columns: 1fr minmax(18rem, 25rem); align-items: end; }
  .product-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (min-width: 1020px) {
  .product-grid { grid-template-columns: repeat(3, 1fr); }
}

What Do the Breakpoints Change?

The layout gains two product columns at 700px. It gains three columns at 1020px.

The narrow layout remains the default. This keeps the storefront usable before wider-screen enhancements apply.

  • Save src/index.css.
  • Resize the browser to a narrow mobile-width view.

You should see one product card per row with horizontally scrollable category controls.

Are the Styles Missing?

  • Confirm that src/main.jsx still imports ./index.css.
  • Check that each CSS rule ends with a closing brace.
  • Confirm that the selector names match the className values in src/App.jsx.

Still stuck? Help me find why my Bean & Brew CSS is not applying.

✔️ Awesome, I've got everything!

Your menu now responds across narrow and wide layouts. Save the stylesheet before the final interaction check.

ⓧ I'd like to double check the full code

:root {
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #2b1d16;
  background: #f7f1e8;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background: radial-gradient(circle at top right, rgba(210, 156, 92, 0.2), transparent 28rem), #f7f1e8;
}
button, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid #d28a3c; outline-offset: 3px; }
button { cursor: pointer; }
.demo-banner { padding: 0.65rem 1rem; color: #fff8ed; background: #5c2f1c; text-align: center; font-size: 0.9rem; font-weight: 700; }
.site-header { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 5vw, 5rem); background: rgba(247, 241, 232, 0.94); border-bottom: 1px solid rgba(74, 45, 32, 0.12); backdrop-filter: blur(12px); }
.brand { display: inline-flex; align-items: center; gap: 0.7rem; color: #2b1d16; text-decoration: none; font-weight: 900; }
.brand-mark { display: grid; width: 2.5rem; height: 2.5rem; place-items: center; color: #fff8ed; background: #6f3d26; border-radius: 50%; font-size: 0.78rem; letter-spacing: 0.06em; }
.site-header nav { display: flex; align-items: center; gap: 1rem; }
.site-header nav a { display: none; color: #4d3428; text-decoration: none; font-weight: 700; }
.cart-button, .product-footer button, .checkout-button { border: 0; border-radius: 999px; color: #fff; background: #6f3d26; font-weight: 800; }
.cart-button { padding: 0.75rem 1rem; }
.cart-button span { display: inline-grid; min-width: 1.5rem; min-height: 1.5rem; margin-left: 0.35rem; place-items: center; color: #6f3d26; background: #fff8ed; border-radius: 50%; }
main { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; }
.hero { display: grid; gap: 2rem; padding: clamp(3rem, 8vw, 7rem) 0 3rem; }
.eyebrow { margin: 0 0 0.5rem; color: #9b542d; font-size: 0.78rem; font-weight: 900; letter-spacing: 0.14em; text-transform: uppercase; }
.hero h1, .section-heading h2, .cart-heading h2 { margin: 0; font-family: Georgia, "Times New Roman", serif; line-height: 1.02; }
.hero h1 { max-width: 11ch; font-size: clamp(3rem, 9vw, 6.6rem); }
.hero-copy > p:not(.eyebrow) { max-width: 39rem; color: #6c5144; font-size: 1.08rem; line-height: 1.75; }
.primary-link { display: inline-flex; margin-top: 0.5rem; padding: 0.9rem 1.25rem; color: #fff; background: #6f3d26; border-radius: 999px; text-decoration: none; font-weight: 800; }
.hero-card { display: grid; min-height: 20rem; padding: 2rem; place-content: end start; color: #fff8ed; background: linear-gradient(145deg, rgba(255,255,255,0.08), transparent), #3a2118; border-radius: 2rem; box-shadow: 0 1.5rem 4rem rgba(70,36,22,0.22); }
.hero-cup { display: grid; width: 7rem; height: 7rem; margin-bottom: 3rem; place-items: center; border: 2px solid #d8a064; border-radius: 50% 50% 42% 42%; color: #d8a064; font-family: Georgia, "Times New Roman", serif; font-size: 1.5rem; font-weight: 900; letter-spacing: 0.08em; }
.hero-card strong { font-size: 1.35rem; }
.hero-card p { margin-bottom: 0; color: #e3d2c2; }
.store-strip { display: grid; gap: 1rem; padding: 1.25rem; background: #fffaf2; border: 1px solid rgba(74,45,32,0.12); border-radius: 1.25rem; }
.store-strip div { display: grid; gap: 0.2rem; }
.store-strip span { color: #8b6b5b; font-size: 0.82rem; }
.menu-section { padding: 5rem 0; }
.section-heading { display: grid; gap: 1.5rem; margin-bottom: 1.5rem; }
.section-heading h2, .cart-heading h2 { font-size: clamp(2.3rem, 6vw, 4rem); }
.search-field { display: grid; gap: 0.45rem; font-size: 0.86rem; font-weight: 800; }
.search-field input { width: 100%; padding: 0.9rem 1rem; color: #2b1d16; background: #fff; border: 1px solid #cdb9aa; border-radius: 0.9rem; }
.category-list { display: flex; gap: 0.6rem; overflow-x: auto; padding: 0.25rem 0 0.75rem; }
.category-list button { flex: 0 0 auto; padding: 0.72rem 1rem; color: #5c3d2e; background: transparent; border: 1px solid #b99c89; border-radius: 999px; font-weight: 800; }
.category-list button.active { color: #fff; background: #6f3d26; border-color: #6f3d26; }
.result-count { color: #806252; font-size: 0.9rem; }
.product-grid { display: grid; gap: 1rem; }
.product-card { overflow: hidden; background: #fffaf2; border: 1px solid rgba(74,45,32,0.12); border-radius: 1.4rem; }
.product-art { display: grid; min-height: 11rem; place-items: center; color: #f8e9d7; background: radial-gradient(circle at 65% 25%, #c58550, transparent 28%), linear-gradient(135deg, #8b4f31, #3b2117); font-family: Georgia, "Times New Roman", serif; font-size: 2.4rem; font-weight: 900; letter-spacing: 0.08em; }
.product-content { padding: 1.2rem; }
.product-category { color: #9b542d; font-size: 0.78rem; font-weight: 900; text-transform: uppercase; }
.product-content h3 { margin: 0.45rem 0; font-size: 1.25rem; }
.product-content p { min-height: 3rem; margin: 0; color: #73594c; line-height: 1.55; }
.product-footer { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; margin-top: 1.2rem; }
.product-footer button { padding: 0.75rem 0.95rem; }
.empty-results, .empty-cart { padding: 2rem; color: #73594c; background: #fffaf2; border-radius: 1rem; text-align: center; }
.order-message { min-height: 1.5rem; margin: -3rem 0 3rem; color: #2d6a43; font-weight: 800; text-align: center; }
footer { display: grid; gap: 0.5rem; padding: 2rem 1rem; color: #ead8c8; background: #2f1b14; text-align: center; }
footer p { margin: 0; }

@media (min-width: 700px) {
  .site-header nav a { display: inline; }
  .hero { grid-template-columns: 1.25fr 0.75fr; align-items: center; }
  .store-strip { grid-template-columns: repeat(3, 1fr); }
  .section-heading { grid-template-columns: 1fr minmax(18rem, 25rem); align-items: end; }
  .product-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (min-width: 1020px) {
  .product-grid { grid-template-columns: repeat(3, 1fr); }
}

Before the final check, which product do you expect to remain when the search field contains latte?

  • Return to http://localhost:5173.
  • Enter latte in the labeled search field.

You should see only the Vanilla Latte card. The result count should show one matching product.

  • Clear the search field.
  • Select Bánh ngọt from the category controls.

You should see the Croissant bơ and Tiramisu cards. The result count should show two products.

  • Enter matcha in the search field.

You should see the empty-results message because no pastry matches that search.

  • Clear the search field.
  • Press the Tab key to move through the search field and category buttons.

You should see a visible focus outline on each interactive control. That completes the keyboard and filtering check.

The searchable storefront is working across screen sizes. Next, you will connect each product button to an interactive cart with quantities and running totals.

Add the Interactive Cart

Your searchable menu helps visitors find a drink or pastry. However, they still cannot assemble a demo order.

In this step, you will use React component state to build an interactive cart. The first version keeps each selection in memory for the current browser session.

In this step, get ready to:
  • Manage cart items with immutable state updates.
  • Build an accessible cart panel with quantity controls.
  • Test the cart before refreshing the browser.
Build cart state and updates

Each cart item combines a product record with a quantity. Immutable updates create a new array whenever an item changes, which gives React a clear signal to render the updated count or total.

  • Switch back to src/App.jsx in Visual Studio Code.
  • Add the three cart state values below the existing query state.
  • Add the count and total calculations below filteredProducts.
  • Replace the temporary add-to-cart handler with the code below:
  const [cart, setCart] = useState([]);
  const [cartOpen, setCartOpen] = useState(false);
  const [orderMessage, setOrderMessage] = useState("");

  const itemCount = cart.reduce((sum, item) => sum + item.quantity, 0);
  const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);

  function addToCart(product) {
    setCart((currentCart) => {
      const existingItem = currentCart.find((item) => item.id === product.id);
      if (existingItem) {
        return currentCart.map((item) => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item);
      }
      return [...currentCart, { ...product, quantity: 1 }];
    });
    setOrderMessage("");
    setCartOpen(true);
  }

How does adding an item work?

  • The two reduce() calls derive the current item count and order total from the cart.
  • The find() call checks whether the selected product already exists in the cart.
  • The map() call returns a new array with a higher quantity for an existing item.
  • The spread syntax adds a new product with a starting quantity of 1.
  • Add the remaining cart functions below addToCart by pasting this code:
  function changeQuantity(productId, amount) {
    setCart((currentCart) => currentCart
      .map((item) => item.id === productId ? { ...item, quantity: item.quantity + amount } : item)
      .filter((item) => item.quantity > 0));
    setOrderMessage("");
  }

  function removeFromCart(productId) {
    setCart((currentCart) => currentCart.filter((item) => item.id !== productId));
    setOrderMessage("");
  }

  function confirmDemoOrder() {
    if (cart.length === 0) {
      setOrderMessage("Hãy thêm ít nhất một món vào giỏ hàng demo.");
      return;
    }
    setCart([]);
    setOrderMessage("Đơn hàng demo đã được tạo. Không có dữ liệu hoặc thanh toán nào được gửi đi.");
  }

What do these functions control?

  • The changeQuantity() function adjusts one matching item. Its final filter removes any item whose quantity reaches zero.
  • The removeFromCart() function keeps every item except the selected product.
  • The confirmDemoOrder() function clears a non-empty cart. It also creates the educational confirmation message.
  • Find the existing navigation inside site-header.
  • Replace that navigation with the version below:
        <nav aria-label="Điều hướng chính">
          <a href="#menu">Menu</a>
          <a href="#visit">Ghé quán</a>
          <button className="cart-button" onClick={() => setCartOpen(true)}>
            Giỏ hàng <span>{itemCount}</span>
          </button>
        </nav>

What changes in the header?

The cart button displays the derived item count. Its click handler changes cartOpen so the cart interface can open.

  • Find the button inside product-footer.
  • Connect the button to addToCart with this line:
                    <button onClick={() => addToCart(product)}>Thêm vào giỏ</button>

How does the product reach the cart?

Each rendered card passes its own product object into addToCart(). The handler can identify the product by its existing id.

  • Save src/App.jsx.
  • Return to the storefront at http://localhost:5173.
  • Select Thêm vào giỏ on one product card.

You should see the number inside the Giỏ hàng button change from 0 to 1. That first changing count proves your cart state is connected to the menu.

Cart count staying at zero?

  • Check that the product button passes product into addToCart().
  • Confirm that itemCount appears inside the cart button span.

Still stuck? Help me trace why my Bean & Brew cart count does not update when I select a product.

Build the cart panel

A cart panel keeps the visitor on the menu while showing the current order. The backdrop provides a quick dismissal area, while the side panel holds the controls and total.

  • Find the footer near the end of the returned interface.
  • Add the cart shell below the footer by pasting this code:
      {cartOpen && (
        <div className="cart-layer">
          <div className="cart-backdrop" onClick={() => setCartOpen(false)} />
          <aside className="cart-panel" aria-labelledby="cart-title">
            <div className="cart-heading">
              <div><p className="eyebrow">Đơn demo</p><h2 id="cart-title">Giỏ hàng</h2></div>
              <button className="close-button" onClick={() => setCartOpen(false)} aria-label="Đóng giỏ hàng">Đóng</button>
            </div>

            <div className="cart-summary">
              <div><span>Tổng cộng</span><strong>{formatPrice(total)}</strong></div>
              <button className="checkout-button" onClick={confirmDemoOrder}>Xác nhận đơn demo</button>
              <p>Không nhập thông tin cá nhân hoặc thanh toán trên bản mẫu này.</p>
            </div>
          </aside>
        </div>
      )}

What does the cart shell provide?

  • The cartOpen condition keeps the entire layer out of the interface while the cart is closed.
  • The backdrop and close button both set cartOpen to false.
  • The summary formats the derived total with the existing formatPrice() helper.
  • Save src/App.jsx.
  • Select the Giỏ hàng button in the header.

You should see the cart heading and total appear below the storefront content. The close button and backdrop should both hide that content again.

  • Switch back to src/index.css.
  • Extend the existing shared selector rules so they match these lines:
.cart-button, .product-footer button, .checkout-button { border: 0; border-radius: 999px; color: #fff; background: #6f3d26; font-weight: 800; }
.cart-button { padding: 0.75rem 1rem; }
.cart-button span { display: inline-grid; min-width: 1.5rem; min-height: 1.5rem; margin-left: 0.35rem; place-items: center; color: #6f3d26; background: #fff8ed; border-radius: 50%; }
.hero h1, .section-heading h2, .cart-heading h2 { margin: 0; font-family: Georgia, "Times New Roman", serif; line-height: 1.02; }
.section-heading h2, .cart-heading h2 { font-size: clamp(2.3rem, 6vw, 4rem); }
.empty-results, .empty-cart { padding: 2rem; color: #73594c; background: #fffaf2; border-radius: 1rem; text-align: center; }

Why extend the shared rules?

The new cart controls reuse the storefront's existing button, heading, and empty-state styling. Shared selectors keep those repeated visual decisions in one place.

  • Add the cart layout rules below the existing footer styles by pasting this code:
.cart-layer { position: fixed; z-index: 30; inset: 0; }
.cart-backdrop { position: absolute; inset: 0; background: rgba(31,18,13,0.55); }
.cart-panel { position: absolute; top: 0; right: 0; display: flex; width: min(100%, 30rem); height: 100%; padding: 1.4rem; flex-direction: column; background: #fffaf2; box-shadow: -1rem 0 3rem rgba(31,18,13,0.2); overflow-y: auto; }
.cart-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.close-button, .quantity-controls button, .remove-button { color: #5c3d2e; background: transparent; border: 1px solid #b99c89; border-radius: 999px; font-weight: 800; }
.close-button { padding: 0.65rem 0.9rem; }
.cart-list { display: grid; gap: 0.8rem; margin-top: 1.5rem; }
.cart-item { display: grid; grid-template-columns: 1fr auto; gap: 0.8rem; padding: 1rem; background: #f6ecdf; border-radius: 1rem; }
.cart-item h3, .cart-item p { margin: 0; }
.cart-item p { margin-top: 0.25rem; color: #73594c; }
.quantity-controls { display: flex; align-items: center; gap: 0.6rem; }
.quantity-controls button { width: 2rem; height: 2rem; }
.remove-button { width: fit-content; padding: 0.45rem 0.75rem; }
.cart-summary { display: grid; gap: 1rem; margin-top: auto; padding-top: 1.5rem; }
.cart-summary > div { display: flex; justify-content: space-between; font-size: 1.2rem; }
.checkout-button { padding: 1rem; }
.cart-summary p { margin: 0; color: #806252; font-size: 0.83rem; line-height: 1.5; }

How is the panel positioned?

  • The fixed cart layer fills the browser viewport above the sticky header.
  • The backdrop covers the page while the panel stays against the right edge.
  • The summary uses automatic top spacing to stay near the bottom of the panel.
  • Save src/index.css.
  • Return to the storefront.
  • Select the Giỏ hàng button.

You should see a dimmed backdrop and a light cart panel attached to the right side of the screen. The total should sit near the bottom of the panel.

  • Return to src/App.jsx.
  • Insert the item list between cart-heading and cart-summary by pasting this code:
            {cart.length === 0 ? (
              <p className="empty-cart">Giỏ hàng đang trống.</p>
            ) : (
              <div className="cart-list">
                {cart.map((item) => (
                  <article className="cart-item" key={item.id}>
                    <div><h3>{item.name}</h3><p>{formatPrice(item.price)}</p></div>
                    <div className="quantity-controls" aria-label={`Số lượng ${item.name}`}>
                      <button onClick={() => changeQuantity(item.id, -1)} aria-label={`Giảm số lượng ${item.name}`}>-</button>
                      <span>{item.quantity}</span>
                      <button onClick={() => changeQuantity(item.id, 1)} aria-label={`Tăng số lượng ${item.name}`}>+</button>
                    </div>
                    <button className="remove-button" onClick={() => removeFromCart(item.id)}>Xóa</button>
                  </article>
                ))}
              </div>
            )}

How does the item list stay accessible?

  • The conditional displays a clear empty state when the cart has no products.
  • The map() call renders one article for each cart item.
  • Each symbol-only quantity button receives an aria-label that names its action and product.
  • The remove control uses visible text so its purpose remains clear.
  • Find the existing empty order-message paragraph below the menu section.
  • Replace it with this live message region:
        <p className="order-message" aria-live="polite">{orderMessage}</p>

Why use a live message region?

The aria-live region announces a new order message without moving keyboard focus. It also displays the same confirmation visibly below the menu.

  • Save src/App.jsx.
  • Return to the storefront.
  • Add Espresso to the cart.
  • Add Tiramisu to the cart.
  • Increase the Espresso quantity with its plus button.

You should see both products in the panel. The Espresso quantity should show 2, while the header count and total update immediately.

  • Decrease the Espresso quantity with its minus button.
  • Remove Tiramisu with its Xóa button.
  • Close the cart with its Đóng button.

The count should return to 1. Reopening the cart should show only Espresso with a total of 35.000đ.

Quantity or total not changing?

  • Check that both quantity buttons pass item.id into changeQuantity().
  • Confirm that the minus button passes -1 while the plus button passes 1.
  • Check that the total multiplies each price by its quantity inside reduce().

Need another pair of eyes? Help me debug the quantity and total calculations in my Bean & Brew React cart.

✔️ Awesome, I've got everything!

Great. Double check that src/App.jsx is saved and that every cart control responds in the browser.

ⓧ I'd like to double check the full code

import { useState } from "react";
import { CATEGORIES, PRODUCTS } from "./data.js";

function formatPrice(value) {
  return `${String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ".")}đ`;
}

export default function App() {
  const [selectedCategory, setSelectedCategory] = useState("Tất cả");
  const [query, setQuery] = useState("");
  const [cart, setCart] = useState([]);
  const [cartOpen, setCartOpen] = useState(false);
  const [orderMessage, setOrderMessage] = useState("");

  const normalizedQuery = query.trim().toLowerCase();
  const filteredProducts = PRODUCTS.filter((product) => {
    const matchesCategory = selectedCategory === "Tất cả" || product.category === selectedCategory;
    const searchableText = `${product.name} ${product.description}`.toLowerCase();
    return matchesCategory && searchableText.includes(normalizedQuery);
  });

  const itemCount = cart.reduce((sum, item) => sum + item.quantity, 0);
  const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);

  function addToCart(product) {
    setCart((currentCart) => {
      const existingItem = currentCart.find((item) => item.id === product.id);
      if (existingItem) {
        return currentCart.map((item) => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item);
      }
      return [...currentCart, { ...product, quantity: 1 }];
    });
    setOrderMessage("");
    setCartOpen(true);
  }

  function changeQuantity(productId, amount) {
    setCart((currentCart) => currentCart
      .map((item) => item.id === productId ? { ...item, quantity: item.quantity + amount } : item)
      .filter((item) => item.quantity > 0));
    setOrderMessage("");
  }

  function removeFromCart(productId) {
    setCart((currentCart) => currentCart.filter((item) => item.id !== productId));
    setOrderMessage("");
  }

  function confirmDemoOrder() {
    if (cart.length === 0) {
      setOrderMessage("Hãy thêm ít nhất một món vào giỏ hàng demo.");
      return;
    }
    setCart([]);
    setOrderMessage("Đơn hàng demo đã được tạo. Không có dữ liệu hoặc thanh toán nào được gửi đi.");
  }

  return (
    <>
      <div className="demo-banner" role="note">
        Bản mẫu giáo dục. Website không nhận đơn thật, thông tin cá nhân hoặc thanh toán.
      </div>

      <header className="site-header">
        <a className="brand" href="#top" aria-label="Bean & Brew, về đầu trang">
          <span className="brand-mark">B&B</span>
          <span>Bean & Brew</span>
        </a>
        <nav aria-label="Điều hướng chính">
          <a href="#menu">Menu</a>
          <a href="#visit">Ghé quán</a>
          <button className="cart-button" onClick={() => setCartOpen(true)}>
            Giỏ hàng <span>{itemCount}</span>
          </button>
        </nav>
      </header>

      <main id="top">
        <section className="hero" aria-labelledby="hero-title">
          <div className="hero-copy">
            <p className="eyebrow">Rang mới mỗi ngày</p>
            <h1 id="hero-title">Một khoảng nghỉ thơm mùi cà phê.</h1>
            <p>Khám phá đồ uống cân bằng, bánh nướng trong ngày và một trải nghiệm đặt món demo thật gọn trên mọi màn hình.</p>
            <a className="primary-link" href="#menu">Xem menu</a>
          </div>
          <div className="hero-card" aria-label="Thông tin nổi bật">
            <span className="hero-cup">B&B</span>
            <strong>Hạt rang vừa</strong>
            <p>Hương cacao, caramel và hạt rang.</p>
          </div>
        </section>

        <section className="store-strip" id="visit" aria-label="Thông tin cửa hàng">
          <div><span>Giờ mở cửa</span><strong>07:00 đến 21:00</strong></div>
          <div><span>Địa chỉ demo</span><strong>25 Đường Hạt Nắng</strong></div>
          <div><span>Hình thức</span><strong>Dùng tại quán và mang đi</strong></div>
        </section>

        <section className="menu-section" id="menu" aria-labelledby="menu-title">
          <div className="section-heading">
            <div><p className="eyebrow">Menu chọn lọc</p><h2 id="menu-title">Chọn món bạn thích</h2></div>
            <label className="search-field" htmlFor="menu-search">
              <span>Tìm trong menu</span>
              <input id="menu-search" type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Ví dụ: latte" />
            </label>
          </div>

          <div className="category-list" aria-label="Lọc theo danh mục">
            {CATEGORIES.map((category) => (
              <button key={category} className={selectedCategory === category ? "active" : ""} aria-pressed={selectedCategory === category} onClick={() => setSelectedCategory(category)}>
                {category}
              </button>
            ))}
          </div>

          <p className="result-count" aria-live="polite">Hiển thị {filteredProducts.length} món</p>

          <div className="product-grid">
            {filteredProducts.map((product) => (
              <article className="product-card" key={product.id}>
                <div className="product-art" aria-hidden="true">{product.art}</div>
                <div className="product-content">
                  <span className="product-category">{product.category}</span>
                  <h3>{product.name}</h3>
                  <p>{product.description}</p>
                  <div className="product-footer">
                    <strong>{formatPrice(product.price)}</strong>
                    <button onClick={() => addToCart(product)}>Thêm vào giỏ</button>
                  </div>
                </div>
              </article>
            ))}
          </div>

          {filteredProducts.length === 0 && <p className="empty-results">Chưa có món phù hợp. Hãy thử từ khóa hoặc danh mục khác.</p>}
        </section>

        <p className="order-message" aria-live="polite">{orderMessage}</p>
      </main>

      <footer>
        <p>Bean & Brew</p>
        <p>Dự án học tập độc lập. Không đại diện cho một cửa hàng có thật.</p>
      </footer>

      {cartOpen && (
        <div className="cart-layer">
          <div className="cart-backdrop" onClick={() => setCartOpen(false)} />
          <aside className="cart-panel" aria-labelledby="cart-title">
            <div className="cart-heading">
              <div><p className="eyebrow">Đơn demo</p><h2 id="cart-title">Giỏ hàng</h2></div>
              <button className="close-button" onClick={() => setCartOpen(false)} aria-label="Đóng giỏ hàng">Đóng</button>
            </div>

            {cart.length === 0 ? (
              <p className="empty-cart">Giỏ hàng đang trống.</p>
            ) : (
              <div className="cart-list">
                {cart.map((item) => (
                  <article className="cart-item" key={item.id}>
                    <div><h3>{item.name}</h3><p>{formatPrice(item.price)}</p></div>
                    <div className="quantity-controls" aria-label={`Số lượng ${item.name}`}>
                      <button onClick={() => changeQuantity(item.id, -1)} aria-label={`Giảm số lượng ${item.name}`}>-</button>
                      <span>{item.quantity}</span>
                      <button onClick={() => changeQuantity(item.id, 1)} aria-label={`Tăng số lượng ${item.name}`}>+</button>
                    </div>
                    <button className="remove-button" onClick={() => removeFromCart(item.id)}>Xóa</button>
                  </article>
                ))}
              </div>
            )}

            <div className="cart-summary">
              <div><span>Tổng cộng</span><strong>{formatPrice(total)}</strong></div>
              <button className="checkout-button" onClick={confirmDemoOrder}>Xác nhận đơn demo</button>
              <p>Không nhập thông tin cá nhân hoặc thanh toán trên bản mẫu này.</p>
            </div>
          </aside>
        </div>
      )}
    </>
  );
}

What should you compare?

Check the state declarations, derived values, cart functions, connected product button, live message region, and cart panel. Your file should keep the search and category interface intact.

✔️ Awesome, my cart is styled!

Great. Double check that src/index.css is saved and that the panel remains usable on a narrow browser window.

ⓧ I'd like to double check the cart styles

:root {
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #2b1d16;
  background: #f7f1e8;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  background: radial-gradient(circle at top right, rgba(210, 156, 92, 0.2), transparent 28rem), #f7f1e8;
}
button, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 3px solid #d28a3c; outline-offset: 3px; }
button { cursor: pointer; }
.demo-banner { padding: 0.65rem 1rem; color: #fff8ed; background: #5c2f1c; text-align: center; font-size: 0.9rem; font-weight: 700; }
.site-header { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 5vw, 5rem); background: rgba(247, 241, 232, 0.94); border-bottom: 1px solid rgba(74, 45, 32, 0.12); backdrop-filter: blur(12px); }
.brand { display: inline-flex; align-items: center; gap: 0.7rem; color: #2b1d16; text-decoration: none; font-weight: 900; }
.brand-mark { display: grid; width: 2.5rem; height: 2.5rem; place-items: center; color: #fff8ed; background: #6f3d26; border-radius: 50%; font-size: 0.78rem; letter-spacing: 0.06em; }
.site-header nav { display: flex; align-items: center; gap: 1rem; }
.site-header nav a { display: none; color: #4d3428; text-decoration: none; font-weight: 700; }
.cart-button, .product-footer button, .checkout-button { border: 0; border-radius: 999px; color: #fff; background: #6f3d26; font-weight: 800; }
.cart-button { padding: 0.75rem 1rem; }
.cart-button span { display: inline-grid; min-width: 1.5rem; min-height: 1.5rem; margin-left: 0.35rem; place-items: center; color: #6f3d26; background: #fff8ed; border-radius: 50%; }
main { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; }
.hero { display: grid; gap: 2rem; padding: clamp(3rem, 8vw, 7rem) 0 3rem; }
.eyebrow { margin: 0 0 0.5rem; color: #9b542d; font-size: 0.78rem; font-weight: 900; letter-spacing: 0.14em; text-transform: uppercase; }
.hero h1, .section-heading h2, .cart-heading h2 { margin: 0; font-family: Georgia, "Times New Roman", serif; line-height: 1.02; }
.hero h1 { max-width: 11ch; font-size: clamp(3rem, 9vw, 6.6rem); }
.hero-copy > p:not(.eyebrow) { max-width: 39rem; color: #6c5144; font-size: 1.08rem; line-height: 1.75; }
.primary-link { display: inline-flex; margin-top: 0.5rem; padding: 0.9rem 1.25rem; color: #fff; background: #6f3d26; border-radius: 999px; text-decoration: none; font-weight: 800; }
.hero-card { display: grid; min-height: 20rem; padding: 2rem; place-content: end start; color: #fff8ed; background: linear-gradient(145deg, rgba(255,255,255,0.08), transparent), #3a2118; border-radius: 2rem; box-shadow: 0 1.5rem 4rem rgba(70,36,22,0.22); }
.hero-cup { display: grid; width: 7rem; height: 7rem; margin-bottom: 3rem; place-items: center; border: 2px solid #d8a064; border-radius: 50% 50% 42% 42%; color: #d8a064; font-family: Georgia, "Times New Roman", serif; font-size: 1.5rem; font-weight: 900; letter-spacing: 0.08em; }
.hero-card strong { font-size: 1.35rem; }
.hero-card p { margin-bottom: 0; color: #e3d2c2; }
.store-strip { display: grid; gap: 1rem; padding: 1.25rem; background: #fffaf2; border: 1px solid rgba(74,45,32,0.12); border-radius: 1.25rem; }
.store-strip div { display: grid; gap: 0.2rem; }
.store-strip span { color: #8b6b5b; font-size: 0.82rem; }
.menu-section { padding: 5rem 0; }
.section-heading { display: grid; gap: 1.5rem; margin-bottom: 1.5rem; }
.section-heading h2, .cart-heading h2 { font-size: clamp(2.3rem, 6vw, 4rem); }
.search-field { display: grid; gap: 0.45rem; font-size: 0.86rem; font-weight: 800; }
.search-field input { width: 100%; padding: 0.9rem 1rem; color: #2b1d16; background: #fff; border: 1px solid #cdb9aa; border-radius: 0.9rem; }
.category-list { display: flex; gap: 0.6rem; overflow-x: auto; padding: 0.25rem 0 0.75rem; }
.category-list button { flex: 0 0 auto; padding: 0.72rem 1rem; color: #5c3d2e; background: transparent; border: 1px solid #b99c89; border-radius: 999px; font-weight: 800; }
.category-list button.active { color: #fff; background: #6f3d26; border-color: #6f3d26; }
.result-count { color: #806252; font-size: 0.9rem; }
.product-grid { display: grid; gap: 1rem; }
.product-card { overflow: hidden; background: #fffaf2; border: 1px solid rgba(74,45,32,0.12); border-radius: 1.4rem; }
.product-art { display: grid; min-height: 11rem; place-items: center; color: #f8e9d7; background: radial-gradient(circle at 65% 25%, #c58550, transparent 28%), linear-gradient(135deg, #8b4f31, #3b2117); font-family: Georgia, "Times New Roman", serif; font-size: 2.4rem; font-weight: 900; letter-spacing: 0.08em; }
.product-content { padding: 1.2rem; }
.product-category { color: #9b542d; font-size: 0.78rem; font-weight: 900; text-transform: uppercase; }
.product-content h3 { margin: 0.45rem 0; font-size: 1.25rem; }
.product-content p { min-height: 3rem; margin: 0; color: #73594c; line-height: 1.55; }
.product-footer { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; margin-top: 1.2rem; }
.product-footer button { padding: 0.75rem 0.95rem; }
.empty-results, .empty-cart { padding: 2rem; color: #73594c; background: #fffaf2; border-radius: 1rem; text-align: center; }
.order-message { min-height: 1.5rem; margin: -3rem 0 3rem; color: #2d6a43; font-weight: 800; text-align: center; }
footer { display: grid; gap: 0.5rem; padding: 2rem 1rem; color: #ead8c8; background: #2f1b14; text-align: center; }
footer p { margin: 0; }
.cart-layer { position: fixed; z-index: 30; inset: 0; }
.cart-backdrop { position: absolute; inset: 0; background: rgba(31,18,13,0.55); }
.cart-panel { position: absolute; top: 0; right: 0; display: flex; width: min(100%, 30rem); height: 100%; padding: 1.4rem; flex-direction: column; background: #fffaf2; box-shadow: -1rem 0 3rem rgba(31,18,13,0.2); overflow-y: auto; }
.cart-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.close-button, .quantity-controls button, .remove-button { color: #5c3d2e; background: transparent; border: 1px solid #b99c89; border-radius: 999px; font-weight: 800; }
.close-button { padding: 0.65rem 0.9rem; }
.cart-list { display: grid; gap: 0.8rem; margin-top: 1.5rem; }
.cart-item { display: grid; grid-template-columns: 1fr auto; gap: 0.8rem; padding: 1rem; background: #f6ecdf; border-radius: 1rem; }
.cart-item h3, .cart-item p { margin: 0; }
.cart-item p { margin-top: 0.25rem; color: #73594c; }
.quantity-controls { display: flex; align-items: center; gap: 0.6rem; }
.quantity-controls button { width: 2rem; height: 2rem; }
.remove-button { width: fit-content; padding: 0.45rem 0.75rem; }
.cart-summary { display: grid; gap: 1rem; margin-top: auto; padding-top: 1.5rem; }
.cart-summary > div { display: flex; justify-content: space-between; font-size: 1.2rem; }
.checkout-button { padding: 1rem; }
.cart-summary p { margin: 0; color: #806252; font-size: 0.83rem; line-height: 1.5; }

@media (min-width: 700px) {
  .site-header nav a { display: inline; }
  .hero { grid-template-columns: 1.25fr 0.75fr; align-items: center; }
  .store-strip { grid-template-columns: repeat(3, 1fr); }
  .section-heading { grid-template-columns: 1fr minmax(18rem, 25rem); align-items: end; }
  .product-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (min-width: 1020px) {
  .product-grid { grid-template-columns: repeat(3, 1fr); }
}

What should you compare?

Confirm that the shared controls include the cart selectors. Then compare the fixed layer, backdrop, panel, item controls, and summary rules.

Test the session-only cart

The cart now handles additions, quantity changes, removals, and a derived total. One final check reveals how this first state model behaves when the page starts again.

  • Add any two products to the cart.
  • Increase one product quantity.
  • Close the cart panel.

Before you refresh the page, do you think the cart count will keep its current value?

  • Refresh the storefront in your browser.
  • Open the cart with the Giỏ hàng button.

You will see the header count return to 0 and the message Giỏ hàng đang trống. inside the panel. This is the intended shortfall of keeping the cart only in component state.

Why did the cart reset?

The cart array lives in memory while the current page is running. A refresh starts the component again, so useState([]) supplies a new empty array.

Your storefront now supports a complete cart interaction during the current session. Next, you will make those selections survive a browser refresh.

Persist the Cart

Your React storefront already lets visitors build a demo order. A refresh currently erases every selection.

Browser storage gives the cart a durable home on the visitor's device. This step restores saved items when the app loads.

In this step, get ready to:
  • Restore saved cart items when the storefront loads.
  • Save each cart update in browser storage.
  • Verify persistence through refreshes and demo checkout.
Restore saved cart state

The cart needs a consistent storage key before it can retrieve saved data. The readCart() initializer handles missing or unavailable storage safely.

  • In src/App.jsx, locate the opening section from the React import through the orderMessage state declaration.
  • Update that opening section so it matches the following code:
import { useEffect, useState } from "react";
import { CATEGORIES, PRODUCTS } from "./data.js";

const STORAGE_KEY = "bean-and-brew-cart";

function formatPrice(value) {
  return `${String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ".")}đ`;
}

function readCart() {
  try {
    const savedCart = window.localStorage.getItem(STORAGE_KEY);
    return savedCart ? JSON.parse(savedCart) : [];
  } catch {
    return [];
  }
}

export default function App() {
  const [selectedCategory, setSelectedCategory] = useState("Tất cả");
  const [query, setQuery] = useState("");
  const [cart, setCart] = useState(readCart);
  const [cartOpen, setCartOpen] = useState(false);
  const [orderMessage, setOrderMessage] = useState("");

How does cart restoration work?

  • The STORAGE_KEY constant identifies one browser storage entry for this cart.
  • The readCart() function retrieves the saved string. JSON parsing converts that string back into an array.
  • The try and catch blocks keep the storefront usable when storage access or parsing fails.
  • Passing readCart to useState restores the cart during its initial setup.
  • Save src/App.jsx.
  • Refresh the storefront at http://localhost:5173.

You should see the complete storefront load with an empty cart when no saved cart exists.

Does the storefront turn blank?

  • Check that the React import includes both useEffect and useState.
  • Check that readCart appears above the App component.
  • Confirm that the cart initializer uses useState(readCart).

Still stuck? Help me debug the persistent cart initializer.

Save each cart change

Restoration only reads the saved cart. A React effect writes the latest cart array whenever its contents change.

  • In src/App.jsx, locate the orderMessage state declaration.
  • Add the following effect immediately below that declaration:
  useEffect(() => {
    try {
      window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cart));
    } catch {
      // The storefront still works in memory if browser storage is unavailable.
    }
  }, [cart]);

What does this effect do?

  • The dependency array containing cart runs the effect after each cart change.
  • The JSON.stringify(cart) call converts the cart array into a string that browser storage can hold.
  • The window.localStorage.setItem call stores that string under the same key used by readCart().
  • The fallback keeps the current session functional when the browser blocks storage.
  • Save src/App.jsx.
  • Add Vanilla Latte to the cart.
  • Select the quantity increase button once.

The cart should now show a quantity of 2 for Vanilla Latte.

Before you refresh, consider whether the cart will remember the current quantity.

  • Refresh the storefront.

You should see Vanilla Latte remain in the cart with a quantity of 2. That refresh now proves the saved array was restored.

Does the cart still reset?

  • Confirm that STORAGE_KEY uses bean-and-brew-cart in both storage operations.
  • Confirm that the effect appears inside the App component.
  • Check that the effect dependency array contains cart.

Need another pair of eyes? Help me find why my cart disappears after refresh.

✔️ Awesome, I've got everything!

Great. Save src/App.jsx after confirming that Vanilla Latte survives a refresh with its quantity intact.

ⓧ I'd like to double check the full code

Compare src/App.jsx with the complete persistent-cart implementation below.

import { useEffect, useState } from "react";
import { CATEGORIES, PRODUCTS } from "./data.js";

const STORAGE_KEY = "bean-and-brew-cart";

function formatPrice(value) {
  return `${String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ".")}đ`;
}

function readCart() {
  try {
    const savedCart = window.localStorage.getItem(STORAGE_KEY);
    return savedCart ? JSON.parse(savedCart) : [];
  } catch {
    return [];
  }
}

export default function App() {
  const [selectedCategory, setSelectedCategory] = useState("Tất cả");
  const [query, setQuery] = useState("");
  const [cart, setCart] = useState(readCart);
  const [cartOpen, setCartOpen] = useState(false);
  const [orderMessage, setOrderMessage] = useState("");

  useEffect(() => {
    try {
      window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cart));
    } catch {
      // The storefront still works in memory if browser storage is unavailable.
    }
  }, [cart]);

  const normalizedQuery = query.trim().toLowerCase();
  const filteredProducts = PRODUCTS.filter((product) => {
    const matchesCategory = selectedCategory === "Tất cả" || product.category === selectedCategory;
    const searchableText = `${product.name} ${product.description}`.toLowerCase();
    return matchesCategory && searchableText.includes(normalizedQuery);
  });

  const itemCount = cart.reduce((sum, item) => sum + item.quantity, 0);
  const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);

  function addToCart(product) {
    setCart((currentCart) => {
      const existingItem = currentCart.find((item) => item.id === product.id);
      if (existingItem) {
        return currentCart.map((item) => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item);
      }
      return [...currentCart, { ...product, quantity: 1 }];
    });
    setOrderMessage("");
    setCartOpen(true);
  }

  function changeQuantity(productId, amount) {
    setCart((currentCart) => currentCart
      .map((item) => item.id === productId ? { ...item, quantity: item.quantity + amount } : item)
      .filter((item) => item.quantity > 0));
    setOrderMessage("");
  }

  function removeFromCart(productId) {
    setCart((currentCart) => currentCart.filter((item) => item.id !== productId));
    setOrderMessage("");
  }

  function confirmDemoOrder() {
    if (cart.length === 0) {
      setOrderMessage("Hãy thêm ít nhất một món vào giỏ hàng demo.");
      return;
    }
    setCart([]);
    setOrderMessage("Đơn hàng demo đã được tạo. Không có dữ liệu hoặc thanh toán nào được gửi đi.");
  }

  return (
    <>
      <div className="demo-banner" role="note">
        Bản mẫu giáo dục. Website không nhận đơn thật, thông tin cá nhân hoặc thanh toán.
      </div>

      <header className="site-header">
        <a className="brand" href="#top" aria-label="Bean & Brew, về đầu trang">
          <span className="brand-mark">B&B</span>
          <span>Bean & Brew</span>
        </a>
        <nav aria-label="Điều hướng chính">
          <a href="#menu">Menu</a>
          <a href="#visit">Ghé quán</a>
          <button className="cart-button" onClick={() => setCartOpen(true)}>
            Giỏ hàng <span>{itemCount}</span>
          </button>
        </nav>
      </header>

      <main id="top">
        <section className="hero" aria-labelledby="hero-title">
          <div className="hero-copy">
            <p className="eyebrow">Rang mới mỗi ngày</p>
            <h1 id="hero-title">Một khoảng nghỉ thơm mùi cà phê.</h1>
            <p>Khám phá đồ uống cân bằng, bánh nướng trong ngày và một trải nghiệm đặt món demo thật gọn trên mọi màn hình.</p>
            <a className="primary-link" href="#menu">Xem menu</a>
          </div>
          <div className="hero-card" aria-label="Thông tin nổi bật">
            <span className="hero-cup">B&B</span>
            <strong>Hạt rang vừa</strong>
            <p>Hương cacao, caramel và hạt rang.</p>
          </div>
        </section>

        <section className="store-strip" id="visit" aria-label="Thông tin cửa hàng">
          <div><span>Giờ mở cửa</span><strong>07:00 đến 21:00</strong></div>
          <div><span>Địa chỉ demo</span><strong>25 Đường Hạt Nắng</strong></div>
          <div><span>Hình thức</span><strong>Dùng tại quán và mang đi</strong></div>
        </section>

        <section className="menu-section" id="menu" aria-labelledby="menu-title">
          <div className="section-heading">
            <div><p className="eyebrow">Menu chọn lọc</p><h2 id="menu-title">Chọn món bạn thích</h2></div>
            <label className="search-field" htmlFor="menu-search">
              <span>Tìm trong menu</span>
              <input id="menu-search" type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Ví dụ: latte" />
            </label>
          </div>

          <div className="category-list" aria-label="Lọc theo danh mục">
            {CATEGORIES.map((category) => (
              <button key={category} className={selectedCategory === category ? "active" : ""} aria-pressed={selectedCategory === category} onClick={() => setSelectedCategory(category)}>
                {category}
              </button>
            ))}
          </div>

          <p className="result-count" aria-live="polite">Hiển thị {filteredProducts.length} món</p>

          <div className="product-grid">
            {filteredProducts.map((product) => (
              <article className="product-card" key={product.id}>
                <div className="product-art" aria-hidden="true">{product.art}</div>
                <div className="product-content">
                  <span className="product-category">{product.category}</span>
                  <h3>{product.name}</h3>
                  <p>{product.description}</p>
                  <div className="product-footer">
                    <strong>{formatPrice(product.price)}</strong>
                    <button onClick={() => addToCart(product)}>Thêm vào giỏ</button>
                  </div>
                </div>
              </article>
            ))}
          </div>

          {filteredProducts.length === 0 && <p className="empty-results">Chưa có món phù hợp. Hãy thử từ khóa hoặc danh mục khác.</p>}
        </section>

        <p className="order-message" aria-live="polite">{orderMessage}</p>
      </main>

      <footer>
        <p>Bean & Brew</p>
        <p>Dự án học tập độc lập. Không đại diện cho một cửa hàng có thật.</p>
      </footer>

      {cartOpen && (
        <div className="cart-layer">
          <div className="cart-backdrop" onClick={() => setCartOpen(false)} />
          <aside className="cart-panel" aria-labelledby="cart-title">
            <div className="cart-heading">
              <div><p className="eyebrow">Đơn demo</p><h2 id="cart-title">Giỏ hàng</h2></div>
              <button className="close-button" onClick={() => setCartOpen(false)} aria-label="Đóng giỏ hàng">Đóng</button>
            </div>

            {cart.length === 0 ? (
              <p className="empty-cart">Giỏ hàng đang trống.</p>
            ) : (
              <div className="cart-list">
                {cart.map((item) => (
                  <article className="cart-item" key={item.id}>
                    <div><h3>{item.name}</h3><p>{formatPrice(item.price)}</p></div>
                    <div className="quantity-controls" aria-label={`Số lượng ${item.name}`}>
                      <button onClick={() => changeQuantity(item.id, -1)} aria-label={`Giảm số lượng ${item.name}`}>-</button>
                      <span>{item.quantity}</span>
                      <button onClick={() => changeQuantity(item.id, 1)} aria-label={`Tăng số lượng ${item.name}`}>+</button>
                    </div>
                    <button className="remove-button" onClick={() => removeFromCart(item.id)}>Xóa</button>
                  </article>
                ))}
              </div>
            )}

            <div className="cart-summary">
              <div><span>Tổng cộng</span><strong>{formatPrice(total)}</strong></div>
              <button className="checkout-button" onClick={confirmDemoOrder}>Xác nhận đơn demo</button>
              <p>Không nhập thông tin cá nhân hoặc thanh toán trên bản mẫu này.</p>
            </div>
          </aside>
        </div>
      )}
    </>
  );
}
Verify removal and demo checkout

Every cart update now reaches the same saved array. Removal and checkout should therefore update the restored state too.

  • Select Xóa for Vanilla Latte.

Vanilla Latte should disappear from the cart.

  • Refresh the storefront.

The removed product should stay gone after the refresh.

  • Add Espresso to the cart.

Before you confirm, consider whether the demo order should leave any cart items behind.

  • Select Xác nhận đơn demo.

You should see the cart reset to empty. The page should display “Đơn hàng demo đã được tạo. Không có dữ liệu hoặc thanh toán nào được gửi đi.” in the live status area.

  • Check the storefront for any name or address field.
  • Check the storefront for any password or payment field.

You should find none of those fields. The checkout remains a clearly labeled educational confirmation.

Why clear the saved cart?

The checkout handler replaces the cart with an empty array. The storage effect then saves that empty array automatically.

A later refresh restores the completed state instead of bringing the old order back.

That refresh gap is closed. Your storefront now preserves cart choices while keeping checkout safely inside the educational demo boundary.

Next, you will turn this local storefront into a public build that visitors can open from a shared link.

Publish the Educational Demo

Your cart now survives refreshes. The local Vite development server proves that the complete storefront works on your computer.

A local address cannot be shared with visitors. In this step, GitHub Actions builds the production site. GitHub Pages publishes that build as a public educational demo.

In this step, get ready to:
  • Generate the production build.
  • Create the automated deployment workflow.
  • Publish the storefront through a public repository.
Build and preview the production site

A production build packages your source into files that a static host can serve. The repository path already exists in your Vite configuration.

  • In the Visual Studio Code file sidebar, select vite.config.js.
  • Confirm that the existing base setting matches this reference:
  base: "/bean-and-brew/",

Why does the base path matter?

The base setting tells Vite that the site lives under the /bean-and-brew/ repository path. This keeps generated asset links working on the project site.

Why static hosting fits this project

GitHub Pages serves the generated HTML, CSS, and JavaScript. Bean & Brew collects no user data. It also includes a prominent educational disclaimer.

This boundary keeps the storefront suitable for static hosting. A real ordering service needs commercial hosting with a verified backend and payment provider.

  • Create a second PowerShell terminal in Visual Studio Code.

Before you build, predict whether Vite packages the development server or creates a separate production bundle.

  • Generate the production build by running:
npm run build

What does this command do?

The build script runs Vite in production mode. Vite writes the deployable result to dist.

  • Confirm that dist now appears in the bean-and-brew file sidebar.

You should see generated site files inside dist. Your source files remain unchanged.

Production build failing?

Confirm that the PowerShell terminal is running inside the bean-and-brew folder. Check that package.json still contains the build script.

Still stuck? Help me diagnose my Vite production build.

Before you start the preview, predict whether this check uses the source files or the new production files.

  • Start the production preview by running:
npm run preview

What does the preview check?

The preview server serves the files from dist. This gives you a local check of the same production bundle that GitHub Pages receives.

  • Open http://localhost:4173 in your browser.

You should see the complete Bean & Brew storefront. The educational disclaimer should remain visible at the top.

  • Stop the preview by pressing Ctrl+C in its PowerShell terminal.

Preview page unavailable?

Keep the preview command running while you load the page. Confirm that the browser address uses port 4173.

Still stuck? Help me fix my Vite preview server.

Add the deployment files

The repository needs a clean source history and an automated deployment recipe. The ignore file excludes generated artifacts. The workflow rebuilds those artifacts on GitHub.

  • In the Visual Studio Code file sidebar, create .gitignore beside package.json.
  • Add the ignored paths using this content:
node_modules
dist
.vscode

What does this file exclude?

  • The node_modules entry keeps installed dependency files out of the repository.
  • The dist entry keeps generated production files out of source control.
  • The .vscode entry keeps local editor settings out of the repository.
  • Save .gitignore.
  • Confirm that the file contains exactly three entries.

✔️ Awesome, I've got everything!

Great. Confirm that .gitignore is saved beside package.json.

ⓧ I'd like to double check the full code

node_modules
dist
.vscode

A workflow describes what GitHub runs after your source reaches main. This workflow installs the locked dependencies. It then builds and deploys the storefront.

  • In the Visual Studio Code file sidebar, create a .github folder inside bean-and-brew.

You should now see .github at the same level as src.

  • Inside .github, create a folder named workflows.

You should now see the nested .github/workflows path.

  • Inside .github/workflows, create deploy.yml.

The deployment workflow now has the required .github/workflows/deploy.yml path.

  • Add the workflow triggers and permissions using this first section:
name: Deploy static content to Pages

on:
  push:
    branches: ["main"]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: true

How does the workflow start?

  • The on section starts a deployment after a push to main.
  • The workflow_dispatch entry also allows a manual run.
  • The permissions allow the workflow to read the source and publish a Pages deployment.
  • The concurrency rule prevents overlapping Pages deployments.
  • Save .github/workflows/deploy.yml.
  • Confirm that the file starts with the workflow name.
  • Append the deployment job below the concurrency section using this code:
jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      - name: Set up Node
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
        with:
          node-version: lts/*
          cache: "npm"
      - name: Install dependencies
        run: npm ci
      - name: Build
        run: npm run build
      - name: Setup Pages
        uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d
      - name: Upload artifact
        uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9
        with:
          path: "./dist"
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346

What does the deployment job do?

  • The checkout step retrieves your committed source.
  • The Node setup step prepares the runtime and npm cache.
  • The npm ci step installs dependencies from package-lock.json.
  • The npm run build step creates dist.
  • The final actions upload the generated site and deploy it to GitHub Pages.
  • Save .github/workflows/deploy.yml.
  • Confirm that the upload step points to the production output folder.

Workflow file looks misaligned?

YAML uses indentation to group settings. Confirm that every step sits below steps.

Still stuck? Help me compare my Pages workflow indentation.

✔️ Awesome, I've got everything!

Great. Confirm that the workflow is saved at .github/workflows/deploy.yml.

ⓧ I'd like to double check the full code

name: Deploy static content to Pages

on:
  push:
    branches: ["main"]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: true

jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      - name: Set up Node
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
        with:
          node-version: lts/*
          cache: "npm"
      - name: Install dependencies
        run: npm ci
      - name: Build
        run: npm run build
      - name: Setup Pages
        uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d
      - name: Upload artifact
        uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9
        with:
          path: "./dist"
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346
Publish with GitHub Actions

The complete source now needs a public GitHub repository. Each push to main gives the workflow a versioned source snapshot to build.

  • In your browser, go to GitHub.
  • Select New repository.
  • Enter bean-and-brew as the repository name.
  • Set the repository visibility to public.
  • Leave every file-creation option disabled.
  • Select Create repository.

You should see an empty public repository named bean-and-brew. It is ready to receive the existing local project.

  • Switch back to the PowerShell terminal in Visual Studio Code.
  • Create the first local commit by running these commands:
git init -b main
git add .
git commit -m "Build coffee shop storefront"

What do these Git commands do?

  • The git init -b main command creates the local repository with main as its first branch.
  • The git add . command stages the project files that are not ignored.
  • The commit command records the staged storefront with a descriptive message.

The terminal should confirm that Git created the first commit. Generated dependency and build folders stay outside that commit.

Commit did not complete?

If Git asks for author details, follow the guidance shown in the terminal. Run the commit again after your author identity is available.

Still stuck? Help me complete my first Git commit.

The first push can open a browser authentication prompt. This flow keeps your GitHub password out of the terminal.

  • Replace YOUR-GITHUB-USERNAME in the command with your GitHub username.
  • Connect the repository and push the commit by running:
git remote add origin https://github.com/YOUR-GITHUB-USERNAME/bean-and-brew.git
git push -u origin main

What happens during the push?

The remote command connects your local repository to origin. The push command uploads main and sets its upstream branch.

The new commit includes the workflow. That push starts the automated deployment.

You should see the project files on the repository page after the push completes. The repository should also contain .github/workflows/deploy.yml.

Push rejected or repository missing?

Confirm that you created an empty repository named bean-and-brew. Check that the username in the remote URL matches your account.

Still stuck? Help me diagnose my first GitHub push.

The deployment can take a few minutes while GitHub installs dependencies and builds the site. A short wait here is expected.

  • On the repository page, select Settings.
  • In the repository sidebar, select Pages.
  • Under Build and deployment, set Source to GitHub Actions.
  • Open the repository's GitHub Actions runs page.
  • Open the workflow run named Deploy static content to Pages.
  • Wait for the workflow run to complete successfully.

The completed run proves that GitHub installed the locked dependencies. It also proves that the workflow built and deployed dist.

Before you open the public URL, predict whether the deployed site preserves the search, cart, persistence, and educational boundary from your local build.

  • Visit https://YOUR-GITHUB-USERNAME.github.io/bean-and-brew/ after substituting your GitHub username.

You should see the complete Bean & Brew storefront at the public project URL. The prominent educational disclaimer should appear above the header.

  • Type latte into the Tìm trong menu search field.

You should see Cà phê sữa đá and Vanilla Latte in the filtered grid.

  • Select Bánh ngọt from the category controls.

You should see the Croissant bơ and Tiramisu cards.

  • Add a product using its Thêm vào giỏ button.

The cart panel should open with the selected product and its current total.

  • Refresh the published page.

The same product and quantity should remain in the cart.

  • Select Xác nhận đơn demo.

The cart should reset to empty. You should see the message that the demo order was created without sending data or payment.

  • Resize the browser to a narrow mobile width.

You should see the product grid collapse to one column. The search and cart controls should remain usable.

  • Return to the top of the published page to confirm the educational disclaimer remains prominent.

You got the full release over the line. Bean & Brew is now a public educational storefront with automated deployment and a clearly defined demo-only boundary.

Secret mission

Secret Mission: Launch a Seasonal Menu

Launch a seasonal campaign by adding a new menu category and two drinks through data alone. The existing storefront will handle their cards, search results, cart totals, and browser persistence without a duplicated component.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep Bean & Brew available, pause publishing for a later return, or remove it completely. The project costs $0 while the public repository uses the included GitHub Free access.

Resources you used:

  • Local development server running in Visual Studio Code.
  • Local bean-and-brew project folder containing node_modules, package-lock.json, and dist.
  • Public GitHub repository named bean-and-brew containing the source and GitHub Actions workflow.
  • Public GitHub Pages deployment for the educational storefront.

Keep everything running

No action is needed. Choose this option if you want the repository and educational site to remain available.

  • Leave the public bean-and-brew repository unchanged.
  • Keep GitHub Pages published for the educational storefront.
  • Retain the local bean-and-brew folder with its generated artifacts.
  • Leave the development server running while you continue testing the seasonal menu.

Your educational demo remains available without an ongoing project charge under the stated GitHub Free conditions.

Pause - I'll come back to this later

Pause the public site while preserving your source code. This removes the current deployment while leaving the repository intact.

  • Return to the Visual Studio Code PowerShell terminal that is running the development server.
  • Press Ctrl+C to stop the development server.
  • Switch back to the bean-and-brew repository on GitHub.
  • Open Settings.
  • Select Pages.
  • Open the menu beside the live-site message.
  • Select Unpublish site.
  • Visit the former Pages address to confirm that it no longer serves the storefront.

That gives you breathing room. The public site is offline while every source file stays ready for your return.

A later successful deployment workflow can publish the site again.

Delete - I don't want to use this again

Removing these resources is permanent. Choose this option only when you no longer need the source or deployment history.

Unpublish the GitHub Pages site:

  • Return to the bean-and-brew repository on GitHub.
  • Open Settings.
  • Select Pages.
  • Open the menu beside the live-site message.
  • Select Unpublish site.
  • Visit the former Pages address to confirm that the storefront is unavailable.

Delete the GitHub repository:

  • Return to the settings area for the bean-and-brew repository.
  • Use the repository deletion control in that settings area.
  • Enter bean-and-brew when GitHub asks you to confirm the repository name.
  • Complete the repository deletion confirmation.
  • Return to the former repository page to confirm that the repository is unavailable.

Delete the local project folder:

  • Return to the Visual Studio Code PowerShell terminal that is running the development server.
  • Press Ctrl+C to stop the development server.
  • Close Visual Studio Code.
  • Use Windows search to open File Explorer.
  • Locate the bean-and-brew folder that was open in Visual Studio Code.
  • Delete the bean-and-brew folder.
  • Confirm that the folder is no longer listed in File Explorer.

The local source, installed dependencies, lockfile, and generated build are now removed with the project folder.

Nice Work!

Nice Work!

You made it! You built Bean & Brew as a responsive React storefront for desktop and mobile screens.

Visitors can discover products through search and category filters. Their cart survives a refresh without collecting personal or payment data.

You've learned how to:

  • Built a searchable menu from structured product data. Category controls narrow the visible products instantly. A responsive layout keeps the storefront usable on narrow screens.
  • Managed the cart through component state. Immutable array updates keep quantity controls and derived totals in sync. window.localStorage preserves the cart after a refresh.
  • Published the educational build with GitHub Actions. GitHub Pages serves the public demo with a prominent disclaimer.
  • Completed the optional Secret Mission by launching a seasonal menu through src/data.js. The unchanged product grid renders both new products without duplicated components.

Ready to quiz yourself?