Build a Mobile Ordering PWA
Build an installable mobile catalogue that sends protected orders to Firestore.
Introduction
30 Second Summary
A small shop can lose an order between the customer’s screen and the staff member who needs to fulfil it. A clear order journey gives both sides something they can trust.
In this project, you will build Pocket Orders, a mobile catalogue that sends customer orders to Cloud Firestore. You will deploy it as a Progressive Web App that customers can install on Android and reopen for browsing after an online visit.
What You'll Build
From your Android home screen, you place a test order through Pocket Orders before opening its saved document in the Firebase console.
By the end of this project, you'll have:
- A responsive product catalogue that displays six products clearly on phone and desktop screens. Customers can add products to a live cart with quantities, line removal, and an updating USD total.
- A protected checkout flow that confirms fake test orders and stores them in Cloud Firestore. Staff can verify each saved order through the Firebase console.
- An installable PWA that launches from the Android home screen. Its catalogue reopens offline after a previous online visit. Checkout shows a connection message whenever submission needs the network.
- Secret Mission: Add instant catalogue search that filters product cards by name, category, or description without clearing the cart.
Are there any prerequisites?
Bring a Windows PC, an Android phone, internet access, and a Google account. The project guides you through setting up Visual Studio Code, Node.js, and Google Chrome when needed.
Before We Start
This is your moment to commit to a mobile catalogue that turns a customer's chosen products into a submitted order. Staff will verify the resulting order document in the Firebase Console Firestore Data tab.
Set Up Your Windows Workspace
Pocket Orders needs a repeatable workspace before the catalogue can become visible in a browser. This workspace keeps the project easy to run again.
Visual Studio Code gives you one place to edit the project files. Node.js supplies the tools that install and run the project.
Vite turns those files into a local page you can test while building. This project uses Vite 8.3.0 with Node.js v24.21.0 LTS.
In this step, get ready to:
- Install Visual Studio Code and the required Node.js version.
- Create the Pocket Orders folder with its starter files.
- Run the starter page with Vite in your local browser.
Install your tools and verify Node.js
Your editor provides the workspace where every project file lives. The Node.js runtime lets the terminal use npm to install Vite.
- Visit the official Visual Studio Code Windows setup guide.
- Download the recommended Visual Studio Code User setup.
- Run the downloaded Windows installer.
- Accept the default installation options.
- Press the Windows key to open Windows Search.
- Type Visual Studio Code into Windows Search.
- Press Enter to open Visual Studio Code.
Why use the User setup?
The Visual Studio Code User setup installs for your Windows account without requiring administrator permissions. It also supports background updates.
- Select Terminal from the Visual Studio Code menu.
- Select New Terminal.
- Check whether the required Node.js version is already available by running this command:
node -v
What does this command do?
The node -v command prints the installed Node.js version. This check tells you whether the runtime is ready or needs attention.
✔️ I see the required Node.js version
Your terminal shows v24.21.0. Node.js is ready for Pocket Orders.
- Continue to the terminal restart below.
ⓧ I see an older version
The installed version cannot provide the exact environment used in this project. Update Node.js before installing the project dependencies.
- Visit the official Node.js download page.
- Download Node.js v24.21.0 LTS for Windows.
- Run the downloaded Windows installer.
- Accept the installer defaults.
- Wait for the installer to confirm completion.
ⓧ Command not found
Windows cannot find Node.js because it is not installed yet. Installing the LTS release also provides npm for the dependency steps ahead.
- Visit the official Node.js download page.
- Download Node.js v24.21.0 LTS for Windows.
- Run the downloaded Windows installer.
- Accept the installer defaults.
- Wait for the installer to confirm completion.
A terminal that was open during installation may still use the old environment. Restarting it lets Visual Studio Code detect Node.js.
- Select the Trash Can button in the terminal panel to close the current terminal.
- Select Terminal from the Visual Studio Code menu.
- Select New Terminal.
Before you check again, what version do you expect the restarted terminal to print?
- Verify Node.js in the restarted terminal by running this command:
node -v
What does this check prove?
The restarted terminal reads the updated Windows environment. Seeing the pinned version confirms that Visual Studio Code can use the correct Node.js installation.
You should see v24.21.0 in the terminal. Your Windows development runtime is now ready.
Still seeing the wrong version?
- Close every Visual Studio Code window before reopening the editor.
- Restart Windows if the terminal still uses the previous environment.
- Run the Node.js installer again if the required version remains unavailable.
Get help with the installation from this troubleshooting prompt.
Google Chrome on your Android phone becomes the mobile test surface later in the project. Confirming it now keeps the deployment step focused on the app itself.
- Check the Android app drawer for Google Chrome.
- Open Google Play if Google Chrome is absent.
- Search for Chrome in Google Play.
- Tap Install on the Google Chrome listing.
- Tap Open after installation finishes.
Google Chrome is ready when you can open its browser screen on the Android phone. That phone can now test the deployed mobile experience later.
Create the Pocket Orders starter files
A Vite project starts with a manifest that defines its scripts and dependencies. The remaining starter files give the browser a small page to load.
- Select File from the Visual Studio Code menu.
- Select Open Folder.
- Choose Desktop in the Windows folder picker.
- Select New Folder.
- Enter pocket-orders as the folder name.
- Select Select Folder.
- Select Yes, I trust the authors if Visual Studio Code asks whether you trust this new folder.
You should see pocket-orders at the top of the Explorer sidebar. This confirms that new terminals now start inside the correct folder.
- Create package.json in the pocket-orders folder from the Explorer sidebar's New File control.
- Add the Vite scripts and pinned dependency by pasting this code into package.json:
{
"name": "pocket-orders",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "8.3.0"
}
}
What does this file define?
- The name field identifies the project as pocket-orders.
- The scripts section gives npm consistent commands for development and production builds.
- The devDependencies section pins Vite to 8.3.0.
- Save package.json with Ctrl+S.
- Install the dependency declared in package.json by running this command in the Visual Studio Code terminal:
npm install
What does this command do?
The npm install command reads package.json and downloads Vite. It also records the resolved packages for repeatable installs.
The terminal should finish with an installation summary and return to a prompt. That result confirms that npm can install the project dependencies.
Did the dependency installation fail?
- Check that the terminal path ends with pocket-orders.
- Check that package.json is saved before running the command again.
- Confirm that the computer still has an internet connection.
Use this troubleshooting prompt if the installation still does not complete.
The starter stylesheet gives the page a predictable viewport. It becomes the foundation for the mobile layout in the next step.
- Create a src folder inside pocket-orders from the Explorer sidebar's New Folder control.
- Create style.css inside the new src folder from the Explorer sidebar's New File control.
- Add the initial page dimensions by pasting this code into src/style.css:
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
What does this stylesheet do?
CSS controls how the page is displayed. This starter rule removes the default outer margin and keeps the page usable at mobile widths.
- Save src/style.css with Ctrl+S.
You should now see style.css inside the src folder in the Explorer sidebar.
The JavaScript entry module connects Vite to the stylesheet. Later steps extend this same file with the catalogue and cart behaviour.
- Create main.js inside the src folder from the Explorer sidebar's New File control.
- Connect the starter stylesheet by pasting this code into src/main.js:
import "./style.css";
What does this module do?
JavaScript modules can import other project files. This import tells Vite to include src/style.css when it serves the page.
- Save src/main.js with Ctrl+S.
You should now see both main.js and style.css inside the src folder.
The HTML entry point provides the visible starter heading. It also loads the JavaScript module that Vite serves.
- Create index.html inside the pocket-orders folder from the Explorer sidebar's New File control.
- Add the starter page by pasting this code into index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catalogue</title>
</head>
<body>
<h1>Pocket Orders</h1>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What does this page do?
- HTML gives the browser the page structure and the visible Pocket Orders heading.
- The viewport setting keeps the page width aligned with the screen on mobile devices.
- The module script loads /src/main.js through Vite.
- Save index.html with Ctrl+S.
You should now see index.html and package.json beside the src folder in the Explorer sidebar.
Are the starter files in the wrong place?
- Keep package.json and index.html directly inside pocket-orders.
- Keep main.js and style.css inside pocket-orders/src.
- Expand the src folder in the Explorer sidebar to confirm both files are nested there.
Use this file structure prompt if the Explorer structure still looks different.
✔️ Awesome, I've got everything!
Your four starter files now match the local Pocket Orders foundation.
- Confirm that every open file is saved before starting Vite.
ⓧ I'd like to double check the full code
Compare each file with the complete starter versions below. Keep every file in the location shown by its filename.
{
"name": "pocket-orders",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "8.3.0"
}
}
What should match in package.json?
Check the project name, module type, three scripts, and Vite version. Valid JSON uses double quotes around every key and string value.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catalogue</title>
</head>
<body>
<h1>Pocket Orders</h1>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What should match in index.html?
Check that the page contains the Pocket Orders heading. The final script element must load /src/main.js as a module.
import "./style.css";
What should match in main.js?
The starter JavaScript module contains one stylesheet import. Its relative path points to style.css in the same folder.
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
What should match in style.css?
The starter stylesheet contains one body rule. Check each property value before saving the file.
Start the local app
The project files are ready to become a running page. Vite starts a development server that watches the folder and serves the latest saved version.
The server keeps control of this terminal while it runs. You can stop it later with Ctrl+C.
Before you start the server, what heading do you expect to see when the local page opens?
- Start the Vite development server by running this command in the Visual Studio Code terminal:
npm run dev
What does this command do?
The npm run dev command runs the dev script from package.json. That script starts Vite and serves the project locally.
The terminal should show a local address of http://localhost:5173. This confirms that Vite is serving the starter files.
- Hold Ctrl while selecting http://localhost:5173 in the terminal.
You should see the Pocket Orders starter heading in the browser. That is your first visible local result.
Does the starter page fail to open?
- Check that the terminal path ends with pocket-orders.
- Check that package.json contains the dev script shown above.
- Check that index.html is directly inside the pocket-orders folder.
Use this local server prompt if the local server still does not work.
Your Windows workspace now turns saved project files into a live browser page. Next, you will replace this starter with the mobile catalogue and working cart.
Build the Catalogue and Cart
Your local workspace already serves a starter page. Pocket Orders now needs a customer experience that works comfortably on a phone.
This step builds the catalogue first. It also exposes the limit of keeping an order only in browser state. Staff receive no record when that temporary state disappears.
In this step, get ready to:
- Build the mobile-first catalogue interface.
- Add live cart quantities plus totals.
- Experience the local-only checkout limitation.
Build the mobile page structure
The page structure gives each part of the ordering flow a predictable home. You will build it in the existing index.html file inside Visual Studio Code.
- Select index.html in the Explorer sidebar.
- Select the current contents of index.html.
- Replace the starter page with the catalogue shell by copying this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catalogue</title>
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Mobile catalogue</p>
<h1>Pocket Orders</h1>
</div>
<span id="network-status" class="network-status" role="status">Checking connection</span>
</header>
<main class="layout">
<section class="catalogue" aria-labelledby="catalogue-title">
<div class="hero">
<p class="eyebrow">Fresh picks</p>
<h2 id="catalogue-title">Build your order</h2>
<p>Tap a product to add it to your cart.</p>
</div>
<div id="product-grid" class="product-grid"></div>
</section>
</main>
<footer>Demo catalogue. Use fake customer details while learning.</footer>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What does this structure do?
- The site-header introduces Pocket Orders beside its connection status.
- The catalogue section explains how customers begin an order.
- The empty product-grid gives JavaScript a specific place to render product cards.
- Save index.html.
- Switch back to http://localhost:5173 in your browser.
You should see the Pocket Orders header above the catalogue introduction. The connection badge should currently read Checking connection.
Still seeing the starter page?
- Confirm that you replaced the contents of index.html inside the open pocket-orders folder.
- Check that the browser address is http://localhost:5173.
Help me find why my updated index.html is not appearing in the Vite browser page.
The cart panel keeps the order summary beside the catalogue on wide screens. On a phone, the responsive layout moves it below the products.
- Return to index.html.
- Find the closing </section> immediately above </main>.
- Insert the cart panel after that closing tag by copying this code:
<aside class="cart-panel" aria-labelledby="cart-title">
<div class="cart-heading">
<h2 id="cart-title">Your order</h2>
<span id="cart-count" class="cart-count">0</span>
</div>
<div id="cart-items" class="cart-items"></div>
<div class="cart-total">
<span>Total</span>
<strong id="cart-total">$0.00</strong>
</div>
<form id="order-form" class="order-form">
<label for="customer-name">Customer name</label>
<input id="customer-name" name="customerName" minlength="2" maxlength="60" required />
<label for="customer-phone">Phone number</label>
<input id="customer-phone" name="phone" type="tel" minlength="7" maxlength="20" required />
<button id="submit-order" class="primary-button" type="submit">Place order</button>
<p id="form-message" class="form-message" aria-live="polite"></p>
</form>
</aside>
How does the cart panel work?
- The cart-count reports the number of units in the order.
- The cart-items container receives each cart line from JavaScript.
- The order-form uses required customer fields before allowing submission.
- The form-message provides checkout feedback without replacing the page.
- Save index.html.
- Return to the browser page.
You should see a Your order panel with a zero count. It should also contain the customer form.
Cart panel missing?
- Confirm that the aside sits inside main.
- Check that every opening HTML tag has its matching closing tag.
Help me locate a missing or misplaced tag in my Pocket Orders index.html file.
A responsive interface adapts the same content to different screen widths. The next styles make the catalogue readable without creating a separate mobile page.
- Select src/style.css in the Explorer sidebar.
- Select the current contents of src/style.css.
- Replace the starter styles with the page foundations by copying this code:
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #17352d;
background: #f6f3ec;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
What do these foundations control?
- The root styles establish the typeface plus the page colours.
- The universal box rule keeps element sizing predictable.
- The body rules remove the browser margin while preserving a phone-friendly minimum width.
- Save src/style.css.
- Return to the browser page.
You should see the background change to a warm off-white colour. The page should also use the new dark green text colour.
Styles not changing?
- Confirm that src/main.js still imports ./style.css.
- Check that the file is named style.css without an extra extension.
Help me troubleshoot why src/style.css is not affecting my Pocket Orders page.
- Add the header styles at the bottom of src/style.css by copying this code:
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem clamp(1rem, 4vw, 3rem);
color: white;
background: #1b5e4b;
}
.site-header h1,
.hero h2,
.cart-heading h2 {
margin: 0;
}
.eyebrow,
.product-category {
margin: 0 0 0.25rem;
color: #d98f39;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
What shapes the header?
- Flexbox places the title opposite the connection badge.
- The shared heading rule removes default spacing from the main section titles.
- The eyebrow style creates the small uppercase labels used throughout the catalogue.
- Save src/style.css.
- Return to the browser page.
You should see a dark green header with white text. The orange eyebrow labels should now use compact uppercase lettering.
Header still unstyled?
- Check that the selector begins with .site-header.
- Confirm that index.html uses class="site-header" on the header.
Help me compare the site-header class in my HTML with its CSS selector.
- Add the connection badge plus desktop layout styles at the bottom of src/style.css by copying this code:
.network-status {
padding: 0.45rem 0.7rem;
border-radius: 999px;
color: #17352d;
background: #d9f5e8;
font-size: 0.82rem;
font-weight: 700;
}
.network-status.offline {
color: #7a3416;
background: #ffe0c2;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 1.5rem;
width: min(1180px, calc(100% - 2rem));
margin: 2rem auto;
}
How is the page divided?
- The status badge uses a pill shape that can switch colours when the device goes offline.
- The layout grid reserves the flexible column for products.
- The narrower column keeps the cart easy to scan.
- Save src/style.css.
- Return to the browser page.
You should see the catalogue area beside the cart on a wide browser window. The connection badge should now have a rounded pale green background.
Layout still stacked?
- Widen the browser window beyond 780px.
- Check that both the catalogue section plus the cart panel remain inside main.layout.
Help me troubleshoot why my layout grid does not place the catalogue beside the cart.
- Add the catalogue container styles at the bottom of src/style.css by copying this code:
.hero {
margin-bottom: 1rem;
}
.hero p:last-child {
margin: 0.4rem 0 0;
color: #61736d;
}
.product-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.product-card,
.cart-panel {
border: 1px solid #d9dedb;
border-radius: 1rem;
background: white;
box-shadow: 0 10px 30px rgb(24 67 55 / 8%);
}
.product-card {
padding: 1rem;
}
What does this layer style?
- The hero spacing separates the introduction from the product grid.
- The grid prepares two equal product columns on wider screens.
- The shared surface style gives the cart panel its white card appearance.
- Save src/style.css.
- Return to the browser page.
You should see the cart inside a rounded white panel. It should also have a light border plus a soft shadow.
Cart has no white panel?
- Confirm that the cart uses class="cart-panel" in index.html.
- Check that the shared selector includes the comma after .product-card.
Help me find why the cart-panel CSS is not styling my cart.
- Add the cart structure styles at the bottom of src/style.css by copying this code:
.cart-panel {
align-self: start;
padding: 1rem;
position: sticky;
top: 1rem;
}
.cart-count {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 999px;
color: white;
background: #d98f39;
font-weight: 800;
}
.cart-items {
display: grid;
gap: 0.75rem;
margin: 1rem 0;
}
.cart-line {
padding-bottom: 0.75rem;
border-bottom: 1px solid #e7ebe8;
}
.cart-line p {
margin: 0.2rem 0 0;
color: #61736d;
}
How does the cart stay readable?
- The sticky position keeps the cart near the top during desktop scrolling.
- The count becomes a circular orange badge.
- Each future cart line gets spacing plus a divider.
- Save src/style.css.
- Return to the browser page.
You should see the zero count inside an orange circle. The empty cart area should have more breathing room.
Count is not circular?
- Check that the count element uses id="cart-count" plus class="cart-count".
- Confirm that both the width plus height remain 2rem.
Help me troubleshoot why the cart-count badge is not circular.
- Add the cart controls plus form styles at the bottom of src/style.css by copying this code:
.text-button {
border: 0;
color: #a33d2e;
background: transparent;
font-weight: 700;
}
.cart-total {
padding: 1rem 0;
border-top: 2px solid #17352d;
font-size: 1.1rem;
}
.order-form {
display: grid;
gap: 0.55rem;
}
.order-form label {
margin-top: 0.45rem;
font-weight: 700;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #aab8b3;
border-radius: 0.65rem;
background: white;
}
What makes the form usable?
- The total receives a strong divider that separates it from cart lines.
- The form grid stacks each label with its input.
- Full-width inputs provide larger touch targets on phones.
- Save src/style.css.
- Return to the browser page.
You should see full-width rounded inputs beneath bold labels. A dark divider should sit above the total.
Inputs still look narrow?
- Confirm that the input rule includes width: 100%.
- Check that the earlier universal rule still sets box-sizing: border-box.
Help me troubleshoot why my Pocket Orders inputs do not fill the form width.
- Add the final control states at the bottom of src/style.css by copying this code:
input:focus {
outline: 3px solid rgb(217 143 57 / 30%);
border-color: #d98f39;
}
.primary-button {
margin-top: 0.7rem;
padding: 0.85rem 1rem;
}
.primary-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.form-message {
min-height: 1.5rem;
margin: 0.25rem 0 0;
color: #1b5e4b;
font-weight: 700;
}
.empty-state {
color: #61736d;
}
footer {
padding: 1rem;
color: #61736d;
text-align: center;
}
Why define control states?
- The focus outline shows keyboard users which input is active.
- The disabled style signals when checkout is unavailable.
- The message area reserves space for confirmation text without shifting the form.
- Save src/style.css.
- Select the customer name input in the browser.
You should see an orange focus outline around the input. The footer should now be centred beneath the app.
Focus outline missing?
- Click directly inside the customer name input.
- Check that the selector is written as input:focus.
Help me find why the input focus style does not appear in Pocket Orders.
- Add the responsive breakpoints at the bottom of src/style.css by copying this code:
@media (max-width: 780px) {
.layout {
grid-template-columns: 1fr;
margin-top: 1rem;
}
.cart-panel {
position: static;
}
}
@media (max-width: 520px) {
.site-header {
align-items: flex-start;
flex-direction: column;
}
.product-grid {
grid-template-columns: 1fr;
}
}
How do the breakpoints respond?
- At widths up to 780px, the cart moves below the catalogue.
- At widths up to 520px, the header stacks vertically.
- The narrowest product grid uses one column for easier tapping.
- Save src/style.css.
- Narrow the browser window below 780px.
You should see the cart move below the catalogue. Below 520px, the header should also stack.
Mobile layout not changing?
- Confirm that both media queries close with matching braces.
- Check that the viewport meta tag remains in index.html.
Help me troubleshoot why the Pocket Orders media queries do not change the mobile layout.
Render products and calculate the cart
The interface now has places for products plus cart lines. JavaScript turns the six-item catalogue into visible cards while a Map stores each selected product by its ID.
- Select src/main.js in the Explorer sidebar.
- Select the current contents of src/main.js.
- Replace the starter module with the product data plus page references by copying this code:
import "./style.css";
const PRODUCTS = [
{ id: "coffee", name: "House Coffee", category: "Drinks", price: 8.5, icon: "☕", description: "Smooth roast with a cocoa finish." },
{ id: "tea", name: "Citrus Tea", category: "Drinks", price: 6.25, icon: "🍵", description: "Bright tea with orange and lemon." },
{ id: "bread", name: "Country Loaf", category: "Bakery", price: 5.75, icon: "🍞", description: "Crusty loaf baked for sharing." },
{ id: "cookies", name: "Cookie Box", category: "Bakery", price: 9, icon: "🍪", description: "Six soft chocolate cookies." },
{ id: "jam", name: "Berry Jam", category: "Pantry", price: 7.4, icon: "🫐", description: "Small-batch mixed berry spread." },
{ id: "honey", name: "Wildflower Honey", category: "Pantry", price: 11.2, icon: "🍯", description: "Golden honey with floral notes." }
];
const cart = new Map();
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
});
const productGrid = document.querySelector("#product-grid");
const cartItems = document.querySelector("#cart-items");
const cartCount = document.querySelector("#cart-count");
const cartTotal = document.querySelector("#cart-total");
const orderForm = document.querySelector("#order-form");
const submitButton = document.querySelector("#submit-order");
const formMessage = document.querySelector("#form-message");
const networkStatus = document.querySelector("#network-status");
What does this data represent?
- The PRODUCTS array is the local source for all six catalogue items.
- The cart map stores one entry per product ID.
- The money formatter produces consistent USD prices.
- The DOM references connect later functions to specific page elements.
- Add the product renderer below the DOM references by copying this code:
function renderProducts(productList = PRODUCTS) {
productGrid.innerHTML = "";
if (productList.length === 0) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "No products match your search.";
productGrid.append(empty);
return;
}
productGrid.innerHTML = productList
.map(
(product) => `
<article class="product-card">
<span class="product-icon" aria-hidden="true">${product.icon}</span>
<p class="product-category">${product.category}</p>
<h3>${product.name}</h3>
<p>${product.description}</p>
<div class="product-action">
<strong>${money.format(product.price)}</strong>
<button type="button" data-product-id="${product.id}" aria-label="Add ${product.name} to order">Add</button>
</div>
</article>
`
)
.join("");
}
How are product cards created?
- The default parameter renders the complete PRODUCTS array.
- The empty-state branch handles a future filtered list with no matches.
- The map creates one product card for each object.
- The data-product-id attribute carries the clicked product ID into the cart logic.
- Add this initial renderer call at the bottom of src/main.js by copying this code:
renderProducts();
What does this call do?
The call runs renderProducts() after the browser has loaded the module. That turns the local product data into visible cards.
- Save src/main.js.
- Return to the browser page.
You should see six product cards with names plus USD prices. The cards may still need their detailed styling.
Products not appearing?
- Confirm that renderProducts(); sits below the function.
- Check that productGrid selects #product-grid.
Help me troubleshoot why renderProducts does not display the six product cards.
- Return to src/style.css.
- Find the .product-card padding rule above .cart-panel.
- Insert the product detail styles below that rule by copying this code:
.product-card h3,
.product-card p {
margin: 0.35rem 0;
}
.product-card > p:not(.product-category) {
min-height: 2.8rem;
color: #61736d;
}
.product-icon {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
margin-bottom: 0.75rem;
border-radius: 0.8rem;
background: #f6f3ec;
font-size: 1.7rem;
}
How are product details balanced?
- Compact margins keep each card easy to scan.
- A minimum description height aligns the card actions.
- The icon receives its own rounded tile.
- Save src/style.css.
- Return to the browser page.
You should see each product icon inside a pale rounded square. The descriptions should align more consistently across the grid.
Icons still look unstyled?
- Check that each rendered icon uses class="product-icon".
- Confirm that the new CSS sits below the existing .product-card rule.
Help me compare the rendered product-icon class with its CSS selector.
- Add the product action styles below the icon rule by copying this code:
.product-action,
.cart-heading,
.cart-total,
.cart-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.product-action {
margin-top: 0.9rem;
}
.product-action button,
.primary-button {
border: 0;
border-radius: 0.7rem;
color: white;
background: #1b5e4b;
font-weight: 800;
}
.product-action button {
padding: 0.55rem 0.9rem;
}
What aligns the actions?
- The shared flex rule places paired values at opposite sides of their rows.
- Product actions keep the price beside the Add button.
- The shared button rule gives product plus checkout actions the same visual language.
- Save src/style.css.
- Return to the browser page.
You should see a dark green Add button beside each price. The cart heading should place its count on the opposite side.
Add buttons still plain?
- Check that each button remains inside an element with class="product-action".
- Confirm that the shared button selector includes .product-action button.
Help me troubleshoot why the product-action button styles do not apply.
A cart calculation derives its count plus total from the current map contents. Recalculating after every change keeps the display tied to one source of truth.
- Return to src/main.js.
- Insert the cart operations above the final renderProducts(); call by copying this code:
function addToCart(productId) {
const product = PRODUCTS.find((item) => item.id === productId);
if (!product) return;
const existing = cart.get(productId);
cart.set(productId, {
...product,
quantity: existing ? existing.quantity + 1 : 1
});
renderCart();
}
function removeFromCart(productId) {
cart.delete(productId);
renderCart();
}
function getCartTotal() {
return Array.from(cart.values()).reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
How does cart state change?
- The addToCart() function starts new products at quantity one.
- An existing product receives an increased quantity.
- The removeFromCart() function deletes the complete product entry.
- The getCartTotal() function adds every line price multiplied by its quantity.
- Insert the cart renderer below getCartTotal() by copying this code:
function renderCart() {
const items = Array.from(cart.values());
const count = items.reduce((sum, item) => sum + item.quantity, 0);
cartCount.textContent = count;
cartTotal.textContent = money.format(getCartTotal());
submitButton.disabled = items.length === 0;
if (items.length === 0) {
cartItems.innerHTML = '<p class="empty-state">Your cart is ready for its first item.</p>';
return;
}
cartItems.innerHTML = items
.map(
(item) => `
<div class="cart-line">
<div>
<strong>${item.name}</strong>
<p>${item.quantity} × ${money.format(item.price)}</p>
</div>
<button type="button" class="text-button" data-remove-id="${item.id}">Remove</button>
</div>
`
)
.join("");
}
What does the cart renderer update?
- The unit count includes product quantities.
- The total uses the same currency formatter as the catalogue.
- An empty cart disables checkout plus displays a helpful message.
- Each populated line receives a Remove button tied to its product ID.
- Insert the connection function plus click handlers below renderCart() by copying this code:
function updateNetworkStatus() {
const online = navigator.onLine;
networkStatus.textContent = online ? "Online" : "Offline: browsing only";
networkStatus.classList.toggle("offline", !online);
}
productGrid.addEventListener("click", (event) => {
const button = event.target.closest("[data-product-id]");
if (button) addToCart(button.dataset.productId);
});
cartItems.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-id]");
if (button) removeFromCart(button.dataset.removeId);
});
window.addEventListener("online", updateNetworkStatus);
window.addEventListener("offline", updateNetworkStatus);
How do the interactions connect?
- The network function changes the badge from the browser connection state.
- Product-grid event delegation catches clicks from every Add button.
- Cart event delegation catches clicks from dynamically created Remove buttons.
- The network listeners refresh the badge whenever connectivity changes.
- Replace the existing renderProducts(); call at the bottom with these initial render calls:
renderProducts();
renderCart();
updateNetworkStatus();
Why run three initial calls?
The calls populate the catalogue plus the empty-cart message as soon as the module loads. They also replace the temporary connection text with the current network state.
- Save src/main.js.
- Return to the browser page.
- Select the Add button for House Coffee.
- Select the Add button for House Coffee again.
- Select the Add button for Citrus Tea.
You should see a cart count of 3 plus a total of $23.25. House Coffee should show a quantity of 2.
- Select Remove beside Citrus Tea.
You should see Citrus Tea disappear. The count should become 2 while the total becomes $17.00.
Cart not updating?
- Confirm that the rendered Add buttons use data-product-id.
- Check that renderCart() runs at the end of both cart-changing functions.
- Confirm that the total uses item.price * item.quantity.
Help me debug why product clicks do not update my Pocket Orders quantities or total.
Experience the local-only checkout limit
The cart currently lives only inside the page's Map. A temporary checkout message can look successful even though no order reaches staff.
This is the designed limitation for the local version. You will submit the order first so the missing cloud record becomes visible.
- Return to src/main.js.
- Insert the temporary submit handler above the network listeners by copying this code:
orderForm.addEventListener("submit", (event) => {
event.preventDefault();
formMessage.textContent = "";
const items = Array.from(cart.values());
if (items.length === 0) return;
formMessage.textContent = "Order received on this device only.";
cart.clear();
orderForm.reset();
renderCart();
});
What does the mock checkout do?
- The handler prevents the form from reloading the page immediately.
- It stops if the cart contains no products.
- It displays a local confirmation before clearing the in-memory cart.
- It makes no request to a database.
- Save src/main.js.
- Return to the browser page.
- Add House Coffee to the cart.
- Add Country Loaf to the cart.
Before you submit, do you think the confirmation proves that staff received an order?
- Enter Taylor Reed in the Customer name field.
- Enter 5551234567 in the Phone number field.
- Select Place order.
You should see Order received on this device only. beneath the button. The cart should return to zero.
Form does not submit?
- Confirm that the cart contains at least one product before submission.
- Use at least two characters for the customer name.
- Use at least seven characters for the phone number.
Help me troubleshoot why the Pocket Orders mock checkout form does not display its local confirmation.
Before you refresh, do you expect the browser to recover the submitted order?
- Refresh http://localhost:5173.
You should see the cart count return to 0 with no confirmation message. No staff-readable order exists because the local handler never sends the data outside this browser page.
This shortfall is intentional
The interface accepted customer details but created no durable order. The next step fixes that workflow by writing protected orders to Cloud Firestore.
- Compare your completed local files with the reference versions in the second tab.
✔️ Awesome, I've got everything!
Your responsive catalogue now supports product selection plus cart calculations. Keep the development server running for the next step.
ⓧ I'd like to double check the full code
Compare each file carefully. Your local version should not include Firebase imports or PWA assets yet.
{
"name": "pocket-orders",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "8.3.0"
}
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catalogue</title>
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Mobile catalogue</p>
<h1>Pocket Orders</h1>
</div>
<span id="network-status" class="network-status" role="status">Checking connection</span>
</header>
<main class="layout">
<section class="catalogue" aria-labelledby="catalogue-title">
<div class="hero">
<p class="eyebrow">Fresh picks</p>
<h2 id="catalogue-title">Build your order</h2>
<p>Tap a product to add it to your cart.</p>
</div>
<div id="product-grid" class="product-grid"></div>
</section>
<aside class="cart-panel" aria-labelledby="cart-title">
<div class="cart-heading">
<h2 id="cart-title">Your order</h2>
<span id="cart-count" class="cart-count">0</span>
</div>
<div id="cart-items" class="cart-items"></div>
<div class="cart-total">
<span>Total</span>
<strong id="cart-total">$0.00</strong>
</div>
<form id="order-form" class="order-form">
<label for="customer-name">Customer name</label>
<input id="customer-name" name="customerName" minlength="2" maxlength="60" required />
<label for="customer-phone">Phone number</label>
<input id="customer-phone" name="phone" type="tel" minlength="7" maxlength="20" required />
<button id="submit-order" class="primary-button" type="submit">Place order</button>
<p id="form-message" class="form-message" aria-live="polite"></p>
</form>
</aside>
</main>
<footer>Demo catalogue. Use fake customer details while learning.</footer>
<script type="module" src="/src/main.js"></script>
</body>
</html>
import "./style.css";
const PRODUCTS = [
{ id: "coffee", name: "House Coffee", category: "Drinks", price: 8.5, icon: "☕", description: "Smooth roast with a cocoa finish." },
{ id: "tea", name: "Citrus Tea", category: "Drinks", price: 6.25, icon: "🍵", description: "Bright tea with orange and lemon." },
{ id: "bread", name: "Country Loaf", category: "Bakery", price: 5.75, icon: "🍞", description: "Crusty loaf baked for sharing." },
{ id: "cookies", name: "Cookie Box", category: "Bakery", price: 9, icon: "🍪", description: "Six soft chocolate cookies." },
{ id: "jam", name: "Berry Jam", category: "Pantry", price: 7.4, icon: "🫐", description: "Small-batch mixed berry spread." },
{ id: "honey", name: "Wildflower Honey", category: "Pantry", price: 11.2, icon: "🍯", description: "Golden honey with floral notes." }
];
const cart = new Map();
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
});
const productGrid = document.querySelector("#product-grid");
const cartItems = document.querySelector("#cart-items");
const cartCount = document.querySelector("#cart-count");
const cartTotal = document.querySelector("#cart-total");
const orderForm = document.querySelector("#order-form");
const submitButton = document.querySelector("#submit-order");
const formMessage = document.querySelector("#form-message");
const networkStatus = document.querySelector("#network-status");
function renderProducts(productList = PRODUCTS) {
productGrid.innerHTML = "";
if (productList.length === 0) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "No products match your search.";
productGrid.append(empty);
return;
}
productGrid.innerHTML = productList
.map(
(product) => `
<article class="product-card">
<span class="product-icon" aria-hidden="true">${product.icon}</span>
<p class="product-category">${product.category}</p>
<h3>${product.name}</h3>
<p>${product.description}</p>
<div class="product-action">
<strong>${money.format(product.price)}</strong>
<button type="button" data-product-id="${product.id}" aria-label="Add ${product.name} to order">Add</button>
</div>
</article>
`
)
.join("");
}
function addToCart(productId) {
const product = PRODUCTS.find((item) => item.id === productId);
if (!product) return;
const existing = cart.get(productId);
cart.set(productId, {
...product,
quantity: existing ? existing.quantity + 1 : 1
});
renderCart();
}
function removeFromCart(productId) {
cart.delete(productId);
renderCart();
}
function getCartTotal() {
return Array.from(cart.values()).reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
function renderCart() {
const items = Array.from(cart.values());
const count = items.reduce((sum, item) => sum + item.quantity, 0);
cartCount.textContent = count;
cartTotal.textContent = money.format(getCartTotal());
submitButton.disabled = items.length === 0;
if (items.length === 0) {
cartItems.innerHTML = '<p class="empty-state">Your cart is ready for its first item.</p>';
return;
}
cartItems.innerHTML = items
.map(
(item) => `
<div class="cart-line">
<div>
<strong>${item.name}</strong>
<p>${item.quantity} × ${money.format(item.price)}</p>
</div>
<button type="button" class="text-button" data-remove-id="${item.id}">Remove</button>
</div>
`
)
.join("");
}
function updateNetworkStatus() {
const online = navigator.onLine;
networkStatus.textContent = online ? "Online" : "Offline: browsing only";
networkStatus.classList.toggle("offline", !online);
}
productGrid.addEventListener("click", (event) => {
const button = event.target.closest("[data-product-id]");
if (button) addToCart(button.dataset.productId);
});
cartItems.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-id]");
if (button) removeFromCart(button.dataset.removeId);
});
orderForm.addEventListener("submit", (event) => {
event.preventDefault();
formMessage.textContent = "";
const items = Array.from(cart.values());
if (items.length === 0) return;
formMessage.textContent = "Order received on this device only.";
cart.clear();
orderForm.reset();
renderCart();
});
window.addEventListener("online", updateNetworkStatus);
window.addEventListener("offline", updateNetworkStatus);
renderProducts();
renderCart();
updateNetworkStatus();
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #17352d;
background: #f6f3ec;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem clamp(1rem, 4vw, 3rem);
color: white;
background: #1b5e4b;
}
.site-header h1,
.hero h2,
.cart-heading h2 {
margin: 0;
}
.eyebrow,
.product-category {
margin: 0 0 0.25rem;
color: #d98f39;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.network-status {
padding: 0.45rem 0.7rem;
border-radius: 999px;
color: #17352d;
background: #d9f5e8;
font-size: 0.82rem;
font-weight: 700;
}
.network-status.offline {
color: #7a3416;
background: #ffe0c2;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 1.5rem;
width: min(1180px, calc(100% - 2rem));
margin: 2rem auto;
}
.hero {
margin-bottom: 1rem;
}
.hero p:last-child {
margin: 0.4rem 0 0;
color: #61736d;
}
.product-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.product-card,
.cart-panel {
border: 1px solid #d9dedb;
border-radius: 1rem;
background: white;
box-shadow: 0 10px 30px rgb(24 67 55 / 8%);
}
.product-card {
padding: 1rem;
}
.product-card h3,
.product-card p {
margin: 0.35rem 0;
}
.product-card > p:not(.product-category) {
min-height: 2.8rem;
color: #61736d;
}
.product-icon {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
margin-bottom: 0.75rem;
border-radius: 0.8rem;
background: #f6f3ec;
font-size: 1.7rem;
}
.product-action,
.cart-heading,
.cart-total,
.cart-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.product-action {
margin-top: 0.9rem;
}
.product-action button,
.primary-button {
border: 0;
border-radius: 0.7rem;
color: white;
background: #1b5e4b;
font-weight: 800;
}
.product-action button {
padding: 0.55rem 0.9rem;
}
.cart-panel {
align-self: start;
padding: 1rem;
position: sticky;
top: 1rem;
}
.cart-count {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 999px;
color: white;
background: #d98f39;
font-weight: 800;
}
.cart-items {
display: grid;
gap: 0.75rem;
margin: 1rem 0;
}
.cart-line {
padding-bottom: 0.75rem;
border-bottom: 1px solid #e7ebe8;
}
.cart-line p {
margin: 0.2rem 0 0;
color: #61736d;
}
.text-button {
border: 0;
color: #a33d2e;
background: transparent;
font-weight: 700;
}
.cart-total {
padding: 1rem 0;
border-top: 2px solid #17352d;
font-size: 1.1rem;
}
.order-form {
display: grid;
gap: 0.55rem;
}
.order-form label {
margin-top: 0.45rem;
font-weight: 700;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #aab8b3;
border-radius: 0.65rem;
background: white;
}
input:focus {
outline: 3px solid rgb(217 143 57 / 30%);
border-color: #d98f39;
}
.primary-button {
margin-top: 0.7rem;
padding: 0.85rem 1rem;
}
.primary-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.form-message {
min-height: 1.5rem;
margin: 0.25rem 0 0;
color: #1b5e4b;
font-weight: 700;
}
.empty-state {
color: #61736d;
}
footer {
padding: 1rem;
color: #61736d;
text-align: center;
}
@media (max-width: 780px) {
.layout {
grid-template-columns: 1fr;
margin-top: 1rem;
}
.cart-panel {
position: static;
}
}
@media (max-width: 520px) {
.site-header {
align-items: flex-start;
flex-direction: column;
}
.product-grid {
grid-template-columns: 1fr;
}
}
Your catalogue now feels like a real ordering app, but its confirmation has no durable record behind it. Next, you will connect checkout to a protected cloud order collection.
Send Orders to Cloud Firestore
Your catalogue now builds a working cart. The previous checkout also exposed the missing piece: its confirmation vanished without leaving a staff-readable order.
In this step, Cloud Firestore gives every submitted order a durable cloud record. Create-only Security Rules let customer browsers send validated orders while blocking browser-based reads.
In this step, get ready to:
- Create the Firebase project that holds your order database.
- Replace the mock checkout with Cloud Firestore writes.
- Deploy create-only Security Rules for the order collection.
Create the Firebase project and database
A Firebase project keeps the Web app registration beside its database configuration. The Web app registration provides the values your local catalogue needs to connect to the correct project.
Keep Billing Disabled
Creating a cloud project can feel risky when billing is involved. This learning build stays within the free quota with billing disabled.
Use fake customer details throughout the project. A public create-only endpoint still needs authentication plus abuse controls before it can collect real customer information.
- Open the Firebase Console in a new browser tab.
- Select Create a project.
- Enter Pocket Orders as the project name.
- Complete the project prompts without enabling billing.
- Select the Web icon from the project overview.
- Enter Pocket Orders as the app nickname.
- Select Register app.
- Copy the displayed Firebase configuration object.
What Is the Firebase Configuration?
The configuration object identifies the Firebase project that receives your app's requests. You place its values in src/firebase.js later in this step.
The configuration appears in browser code by design. Your Security Rules remain responsible for controlling database access.
- Select Databases & Storage in the Firebase Console sidebar.
- Select Firestore.
- Select Create database.
- Choose the Standard edition.
- Choose the closest available database location.
- Select Production mode.
- Select Create.
Good progress. Your default Cloud Firestore database now exists with client access denied until you deploy the project rules.
Connect checkout to Cloud Firestore
The Firebase JavaScript SDK gives your checkout the functions required to create an order document. The generated document ID becomes the short confirmation shown to the customer.
- Create a second PowerShell terminal from the VS Code terminal panel so the Vite server can keep running.
- Install Firebase JavaScript SDK version 12.19.0 by running:
npm install firebase@12.19.0 --save
What Does This Command Do?
This installs the Firebase JavaScript SDK inside pocket-orders. It also records version 12.19.0 under dependencies in package.json.
- Select package.json in the VS Code Explorer.
You should see firebase at version 12.19.0 under dependencies.
Did the SDK Installation Fail?
Confirm the terminal is inside the pocket-orders folder. The terminal path should end with that folder name.
Check that the earlier Vite server remains in the first terminal. Use the second terminal for this installation.
Need help with the installation?
- Right-click the src folder in the VS Code Explorer.
- Select New File.
- Enter firebase.js as the file name.
- Add the Firebase initialization module by pasting this code:
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: "your-api-key-here",
authDomain: "your-project-id.firebaseapp.com",
projectId: "your-project-id",
storageBucket: "your-storage-bucket-here",
messagingSenderId: "your-sender-id-here",
appId: "your-app-id-here"
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
What Does This Module Do?
- The firebaseConfig object identifies the registered Web app.
- initializeApp() creates the Firebase app instance.
- getFirestore() connects that instance to Cloud Firestore.
- db exports the database connection for src/main.js.
- Replace all six placeholder strings in firebaseConfig with the matching values from the configuration object you copied.
- Save src/firebase.js.
You should now see firebase.js inside the src folder in the Explorer.
- Return to src/main.js.
- Find the existing import "./style.css"; line.
- Replace that line with these imports:
import { addDoc, collection, serverTimestamp } from "firebase/firestore";
import "./style.css";
import { db } from "./firebase.js";
What Do These Imports Provide?
- addDoc() creates a document with a generated ID.
- collection() points the write at the orders collection.
- serverTimestamp() supplies a timestamp from the Firestore server.
- db provides the configured database connection.
- Save src/main.js.
- Return to the local app at http://localhost:5173.
- Refresh the page.
You should still see the complete catalogue with six products. This confirms the Firebase module loads without breaking the interface.
The temporary submit handler still creates only a local confirmation. Replace that complete handler with the following two code parts before saving the file.
- Select the existing mock handler attached to orderForm.
- Delete the selected mock handler.
- Paste this first part in its place:
orderForm.addEventListener("submit", async (event) => {
event.preventDefault();
formMessage.textContent = "";
if (!navigator.onLine) {
formMessage.textContent = "Reconnect to submit this order.";
return;
}
const items = Array.from(cart.values());
if (items.length === 0) return;
const formData = new FormData(orderForm);
const order = {
customerName: formData.get("customerName").trim(),
phone: formData.get("phone").trim(),
items: items.map(({ id, name, price, quantity }) => ({ id, name, price, quantity })),
total: Number(getCartTotal().toFixed(2)),
status: "new",
createdAt: serverTimestamp()
};
How Is the Order Built?
- navigator.onLine stops checkout when the browser reports an offline connection.
- FormData reads the customer values from order-form.
- items keeps only the product fields required by the order record.
- createdAt uses serverTimestamp() so the backend supplies the write time.
This is the first half of the submit handler. The next block sends the prepared order object and closes the handler.
- Place the cursor directly after the closing brace of the order object.
- Paste the second part below it:
submitButton.disabled = true;
submitButton.textContent = "Sending...";
try {
const documentReference = await addDoc(collection(db, "orders"), order);
const shortId = documentReference.id.slice(0, 8).toUpperCase();
formMessage.textContent = `Order ${shortId} received.`;
cart.clear();
orderForm.reset();
renderCart();
} catch (error) {
console.error(error);
formMessage.textContent = "Order not sent. Check the connection and Firebase setup.";
} finally {
submitButton.textContent = "Place order";
submitButton.disabled = cart.size === 0;
}
});
How Is the Order Sent?
- addDoc() sends the order to the orders collection.
- documentReference.id provides the generated document ID used in the confirmation.
- cart.clear() empties the local cart after a successful write.
- finally restores the checkout button after either outcome.
- Save src/main.js.
- Refresh http://localhost:5173.
You should see the six product cards with an empty cart. The cloud checkout code is now loaded and ready for the rules you deploy next.
Did the Catalogue Stop Loading?
Check that both import paths match firebase/firestore and ./firebase.js exactly.
Check that the second handler block ends with });.
Help me find the JavaScript error.
Deploy create-only Security Rules
Your database started in Production mode with customer access denied. The Firebase CLI now deploys a narrow exception that permits validated creates only at /orders/{orderId}.
- Check whether Firebase CLI is already available in the second terminal by running:
firebase --version
What Does This Command Check?
This prints the installed Firebase CLI version. Use the matching tab below to reach the pinned version 15.30.2.
✔️ I see version 15.30.2
The pinned Firebase CLI is installed. Keep using the second terminal for the remaining commands.
ⓧ I see an older version
The installed CLI is older than this project requires. Updating the global package replaces it with the pinned release.
- Install Firebase CLI version 15.30.2 globally by running:
npm install -g firebase-tools@15.30.2
What Does This Command Do?
The global installation makes the firebase command available outside one project. The explicit version keeps this walkthrough consistent.
- Confirm the updated version by running:
firebase --version
What Should You See?
The terminal should print 15.30.2.
ⓧ Command not found
The Firebase CLI is absent from your Windows command path. Install the pinned global package with npm.
- Install Firebase CLI version 15.30.2 globally by running:
npm install -g firebase-tools@15.30.2
What Does This Command Do?
npm installs the Firebase CLI as a global command. The download may pause briefly while npm retrieves the package.
- Confirm the installation by running:
firebase --version
What Should You See?
The terminal should print 15.30.2.
Still Cannot Run Firebase CLI?
Close only the second terminal after installation. Create another PowerShell terminal so Windows can refresh the command path.
Do not stop the first terminal that is running Vite.
Help me restore the Firebase CLI command.
The Firebase configuration file tells the CLI which local rules file to deploy. Its Hosting section also prepares the dist directory for the next step.
- Right-click the pocket-orders folder at the top of the VS Code Explorer.
- Select New File.
- Enter firebase.json as the file name.
- Add the Firebase deployment configuration by pasting:
{
"firestore": {
"rules": "firestore.rules"
},
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}
What Does This Configuration Control?
The firestore.rules mapping tells the CLI where to find the database rules. The Hosting configuration points future deployments at dist.
The rewrite returns index.html for browser routes. You use that Hosting setup in the next step.
- Save firebase.json.
You should now see firebase.json beside package.json in the Explorer.
- Create firestore.rules beside firebase.json using the VS Code Explorer.
- Paste the first part of the rules file:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function validOrder() {
let data = request.resource.data;
return data.keys().hasAll([
'customerName',
'phone',
'items',
'total',
'status',
'createdAt'
]) &&
data.keys().hasOnly([
'customerName',
'phone',
'items',
'total',
'status',
'createdAt'
]) &&
data.customerName is string &&
data.customerName.size() >= 2 &&
data.customerName.size() <= 60 &&
data.phone is string &&
data.phone.size() >= 7 &&
data.phone.size() <= 20 &&
data.items is list &&
How Does the Validation Start?
hasAll() requires every order field. hasOnly() blocks unexpected fields from being added.
The string checks match the length constraints in the customer form. The second rules block completes the list validation and access controls.
- Place the cursor directly after the final &&.
- Paste the rest of the rules file:
data.items.size() > 0 &&
data.items.size() <= 20 &&
data.total is number &&
data.total > 0 &&
data.status == 'new' &&
data.createdAt == request.time;
}
match /orders/{orderId} {
allow create: if validOrder();
allow read, update, delete: if false;
}
match /{document=**} {
allow read, write: if false;
}
}
}
How Is Access Restricted?
The remaining checks require at least one item. They cap the order at 20 items.
request.time confirms that createdAt came from the server timestamp.
Only validated creates are allowed in orders. Customer reads remain denied.
- Save firestore.rules.
- Authenticate Firebase CLI with your Google account by running:
firebase login
What Happens During Login?
The CLI opens a browser-based Google sign-in flow. Approve access for the same Google account that owns your Firebase project.
- Confirm that the CLI can access your projects by running:
firebase projects:list
What Should You See?
The terminal should list your Pocket Orders Firebase project. This proves the signed-in account can access it.
- Select the project for this workspace by running:
firebase use --add
What Does Project Selection Do?
The command asks you to choose a Firebase project. Select the project you created for Pocket Orders.
The CLI stores the local alias assignment in .firebaserc so later deploy commands target the same project.
- Deploy the Cloud Firestore rules by running:
firebase deploy --only firestore
What Does This Deployment Change?
The command publishes firestore.rules to the selected Firebase project. Valid customer creates can now reach the orders collection.
Browser reads remain blocked. Staff can inspect the documents through the Firebase Console.
Did the Rules Deployment Fail?
Confirm firebase.json and firestore.rules are beside package.json.
Run the project selection command again if the terminal says no active project is selected.
Help me diagnose the Firestore deployment.
Before you place the test order, which fields do you expect the staff view to preserve after the browser receives its confirmation?
- Return to http://localhost:5173.
- Add House Coffee to the cart.
- Add Cookie Box to the cart.
- Enter Test Customer in the customer name field.
- Enter 0000000 in the phone number field.
- Select Place order.
You should see a confirmation beginning with Order and ending with received. The cart should return to zero.
- Return to the Firebase Console.
- Select Databases & Storage.
- Select Firestore.
- Select the Data tab.
- Select the orders collection.
- Select the newly generated document ID.
What Should You See?
- The document contains customerName with the fake customer name.
- The document contains the submitted items with quantities and prices.
- The document contains the calculated total.
- The document contains status with the value new.
- The document contains createdAt with a server timestamp.
That is the cloud handoff working. Customers receive a short confirmation while staff retain the complete order document.
Compare the files changed in this step with the cumulative references below. Keep your own Firebase configuration values in src/firebase.js.
✔️ Awesome, I've got everything!
- Save every changed file in the pocket-orders workspace.
ⓧ I'd like to double check the full code
The files changed in this step should match these references. Your existing index.html and src/style.css remain unchanged.
{
"name": "pocket-orders",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"firebase": "12.19.0"
},
"devDependencies": {
"vite": "8.3.0"
}
}
What Should package.json Confirm?
The file keeps the existing Vite scripts. It adds Firebase version 12.19.0 as the application dependency.
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: "your-api-key-here",
authDomain: "your-project-id.firebaseapp.com",
projectId: "your-project-id",
storageBucket: "your-storage-bucket-here",
messagingSenderId: "your-sender-id-here",
appId: "your-app-id-here"
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
Keep Your Copied Configuration
This reference shows the expected structure with placeholder values. Keep the six real values you copied from your registered Web app.
import { addDoc, collection, serverTimestamp } from "firebase/firestore";
import "./style.css";
import { db } from "./firebase.js";
const PRODUCTS = [
{ id: "coffee", name: "House Coffee", category: "Drinks", price: 8.5, icon: "☕", description: "Smooth roast with a cocoa finish." },
{ id: "tea", name: "Citrus Tea", category: "Drinks", price: 6.25, icon: "🍵", description: "Bright tea with orange and lemon." },
{ id: "bread", name: "Country Loaf", category: "Bakery", price: 5.75, icon: "🍞", description: "Crusty loaf baked for sharing." },
{ id: "cookies", name: "Cookie Box", category: "Bakery", price: 9, icon: "🍪", description: "Six soft chocolate cookies." },
{ id: "jam", name: "Berry Jam", category: "Pantry", price: 7.4, icon: "🫐", description: "Small-batch mixed berry spread." },
{ id: "honey", name: "Wildflower Honey", category: "Pantry", price: 11.2, icon: "🍯", description: "Golden honey with floral notes." }
];
const cart = new Map();
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
});
const productGrid = document.querySelector("#product-grid");
const cartItems = document.querySelector("#cart-items");
const cartCount = document.querySelector("#cart-count");
const cartTotal = document.querySelector("#cart-total");
const orderForm = document.querySelector("#order-form");
const submitButton = document.querySelector("#submit-order");
const formMessage = document.querySelector("#form-message");
const networkStatus = document.querySelector("#network-status");
function renderProducts(productList = PRODUCTS) {
productGrid.innerHTML = "";
if (productList.length === 0) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "No products match your search.";
productGrid.append(empty);
return;
}
productGrid.innerHTML = productList
.map(
(product) => `
<article class="product-card">
<span class="product-icon" aria-hidden="true">${product.icon}</span>
<p class="product-category">${product.category}</p>
<h3>${product.name}</h3>
<p>${product.description}</p>
<div class="product-action">
<strong>${money.format(product.price)}</strong>
<button type="button" data-product-id="${product.id}" aria-label="Add ${product.name} to order">Add</button>
</div>
</article>
`
)
.join("");
}
function addToCart(productId) {
const product = PRODUCTS.find((item) => item.id === productId);
if (!product) return;
const existing = cart.get(productId);
cart.set(productId, {
...product,
quantity: existing ? existing.quantity + 1 : 1
});
renderCart();
}
function removeFromCart(productId) {
cart.delete(productId);
renderCart();
}
function getCartTotal() {
return Array.from(cart.values()).reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
function renderCart() {
const items = Array.from(cart.values());
const count = items.reduce((sum, item) => sum + item.quantity, 0);
cartCount.textContent = count;
cartTotal.textContent = money.format(getCartTotal());
submitButton.disabled = items.length === 0;
if (items.length === 0) {
cartItems.innerHTML = '<p class="empty-state">Your cart is ready for its first item.</p>';
return;
}
cartItems.innerHTML = items
.map(
(item) => `
<div class="cart-line">
<div>
<strong>${item.name}</strong>
<p>${item.quantity} × ${money.format(item.price)}</p>
</div>
<button type="button" class="text-button" data-remove-id="${item.id}">Remove</button>
</div>
`
)
.join("");
}
function updateNetworkStatus() {
const online = navigator.onLine;
networkStatus.textContent = online ? "Online" : "Offline: browsing only";
networkStatus.classList.toggle("offline", !online);
}
productGrid.addEventListener("click", (event) => {
const button = event.target.closest("[data-product-id]");
if (button) addToCart(button.dataset.productId);
});
cartItems.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-id]");
if (button) removeFromCart(button.dataset.removeId);
});
orderForm.addEventListener("submit", async (event) => {
event.preventDefault();
formMessage.textContent = "";
if (!navigator.onLine) {
formMessage.textContent = "Reconnect to submit this order.";
return;
}
const items = Array.from(cart.values());
if (items.length === 0) return;
const formData = new FormData(orderForm);
const order = {
customerName: formData.get("customerName").trim(),
phone: formData.get("phone").trim(),
items: items.map(({ id, name, price, quantity }) => ({ id, name, price, quantity })),
total: Number(getCartTotal().toFixed(2)),
status: "new",
createdAt: serverTimestamp()
};
submitButton.disabled = true;
submitButton.textContent = "Sending...";
try {
const documentReference = await addDoc(collection(db, "orders"), order);
const shortId = documentReference.id.slice(0, 8).toUpperCase();
formMessage.textContent = `Order ${shortId} received.`;
cart.clear();
orderForm.reset();
renderCart();
} catch (error) {
console.error(error);
formMessage.textContent = "Order not sent. Check the connection and Firebase setup.";
} finally {
submitButton.textContent = "Place order";
submitButton.disabled = cart.size === 0;
}
});
window.addEventListener("online", updateNetworkStatus);
window.addEventListener("offline", updateNetworkStatus);
renderProducts();
renderCart();
updateNetworkStatus();
What Should src/main.js Contain?
The cumulative module keeps the existing catalogue logic. Its submit handler now builds a validated order and writes it to Cloud Firestore.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function validOrder() {
let data = request.resource.data;
return data.keys().hasAll([
'customerName',
'phone',
'items',
'total',
'status',
'createdAt'
]) &&
data.keys().hasOnly([
'customerName',
'phone',
'items',
'total',
'status',
'createdAt'
]) &&
data.customerName is string &&
data.customerName.size() >= 2 &&
data.customerName.size() <= 60 &&
data.phone is string &&
data.phone.size() >= 7 &&
data.phone.size() <= 20 &&
data.items is list &&
data.items.size() > 0 &&
data.items.size() <= 20 &&
data.total is number &&
data.total > 0 &&
data.status == 'new' &&
data.createdAt == request.time;
}
match /orders/{orderId} {
allow create: if validOrder();
allow read, update, delete: if false;
}
match /{document=**} {
allow read, write: if false;
}
}
}
What Should firestore.rules Protect?
The rules validate the exact order shape. They permit creates at orders while denying customer reads and all other document access.
{
"firestore": {
"rules": "firestore.rules"
},
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}
What Should firebase.json Prepare?
The file maps the Firestore rules for deployment. It also prepares the Vite dist folder for Firebase Hosting.
Your checkout now creates a durable staff-readable order without exposing customer-side reads. Next, you will deploy Pocket Orders as an installable mobile app with repeat offline catalogue access.
Deploy and Install the PWA
Your catalogue now sends protected orders to Cloud Firestore. However, a localhost address cannot reach customers outside your computer.
A Progressive Web App gives Pocket Orders an installable home-screen experience. In this step, you will publish the production build through Firebase Hosting and prove that the catalogue can reopen offline.
In this step, get ready to:
- Add the manifest, app icons, service worker, and production registration.
- Build the production app and deploy it to Firebase Hosting.
- Install Pocket Orders on Android and test its offline catalogue.
Add the PWA files
A web app manifest describes how Pocket Orders looks when installed. Its icons give the installed app a recognisable home-screen identity.
- Create a folder named public inside the pocket-orders folder with the new-folder control at the top of the VS Code Explorer sidebar.
- Create manifest.webmanifest inside the new public folder with the new-file control.
- Add the initial install metadata and standard icon entries by pasting this code:
{
"id": "/",
"name": "Pocket Orders",
"short_name": "Pocket Orders",
"description": "A mobile catalogue and order app for a small shop.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f6f3ec",
"theme_color": "#1b5e4b",
"icons": [
{
"src": "/icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
What does this manifest do?
- The app name identifies Pocket Orders on the Android home screen.
- The display mode opens the installed app in its own window.
- The start URL sends each launch to the catalogue.
- The icon entries provide small and large artwork for the installed app.
- Save public/manifest.webmanifest.
- Confirm manifest.webmanifest appears inside the public folder in the Explorer sidebar.
Manifest file missing?
Check that manifest.webmanifest sits directly inside public. A nested location prevents the final URL from matching the manifest link.
Ask for help with the folder location:
The large icon also needs a version that Android can crop safely for different home-screen shapes.
- Find the second icon object near the bottom of public/manifest.webmanifest.
- Locate this code:
{
"src": "/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
}
What are you locating?
This object provides the general-purpose large icon. The next edit keeps it and adds a second purpose for the same artwork.
- Replace that icon object with this expanded pair:
{
"src": "/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
Why add a maskable icon?
The second entry lets Android crop the large icon into a supported home-screen shape. The central shopping bag remains visible after that crop.
- Save public/manifest.webmanifest.
- Confirm the file now contains three icon objects.
Seeing a JSON warning?
Check for the comma between the two large icon objects. Also confirm that every opening brace has a matching closing brace.
Ask for help checking the structure:
The manifest now points to two files that need to exist. Each SVG draws the same shopping-bag design at a different declared size.
- Create icon-192.svg inside the public folder.
- Draw the small Pocket Orders icon by pasting this code:
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 512 512" role="img" aria-labelledby="title">
<title id="title">Pocket Orders shopping bag</title>
<rect width="512" height="512" rx="112" fill="#1b5e4b"/>
<path d="M128 188h256l-24 188H152z" fill="#f6f3ec"/>
<path d="M184 198c0-44 32-78 72-78s72 34 72 78" fill="none" stroke="#f6f3ec" stroke-width="28" stroke-linecap="round"/>
<circle cx="220" cy="276" r="18" fill="#d98f39"/>
<circle cx="292" cy="276" r="18" fill="#d98f39"/>
<path d="M214 326c24 18 60 18 84 0" fill="none" stroke="#1b5e4b" stroke-width="16" stroke-linecap="round"/>
</svg>
How is the icon built?
- The green rounded rectangle creates the icon background.
- The cream paths form the bag and its handle.
- The orange circles add the two details on the bag.
- Save public/icon-192.svg.
- Confirm icon-192.svg appears below the manifest in the Explorer sidebar.
Icon marked as invalid?
Check that the file starts with the opening SVG tag. Confirm that the final line contains the closing SVG tag.
Ask for help with the markup:
- Create icon-512.svg inside the public folder.
- Draw the large Pocket Orders icon by pasting this code:
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-labelledby="title">
<title id="title">Pocket Orders shopping bag</title>
<rect width="512" height="512" rx="112" fill="#1b5e4b"/>
<path d="M128 188h256l-24 188H152z" fill="#f6f3ec"/>
<path d="M184 198c0-44 32-78 72-78s72 34 72 78" fill="none" stroke="#f6f3ec" stroke-width="28" stroke-linecap="round"/>
<circle cx="220" cy="276" r="18" fill="#d98f39"/>
<circle cx="292" cy="276" r="18" fill="#d98f39"/>
<path d="M214 326c24 18 60 18 84 0" fill="none" stroke="#1b5e4b" stroke-width="16" stroke-linecap="round"/>
</svg>
Why make a second file?
The artwork stays consistent across both icon sizes. The larger dimensions give Android enough image data for installation surfaces that request a large icon.
- Save public/icon-512.svg.
- Confirm both icon files appear inside public.
Only see one icon file?
Check the filenames carefully. The manifest expects icon-192.svg and icon-512.svg directly inside public.
Ask for help comparing the paths:
The browser discovers the installation metadata through links in the page head. The theme colour also matches the green Pocket Orders header.
- Switch back to index.html from earlier.
- Find the viewport metadata line near the top of the file.
- Replace that line with this metadata group:
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1b5e4b" />
<meta name="description" content="A mobile catalogue and order app for a small shop." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon-192.svg" type="image/svg+xml" />
What do these links connect?
- The theme colour gives supported browser surfaces the Pocket Orders green.
- The description summarises the app for browser and installation metadata.
- The manifest link connects the page to its install settings.
- The icon link gives the browser tab its shopping-bag artwork.
- Save index.html.
- Refresh the local Pocket Orders page from earlier.
You should see the catalogue load normally. The browser tab should use the shopping-bag icon.
Favicon still missing?
Confirm the icon path starts with a forward slash. Refreshing the page again can clear a previously cached tab icon.
Ask for help checking the link:
A service worker can intercept requests from the installed app. Pocket Orders uses that ability to return cached catalogue files during a repeat offline visit.
- Create sw.js inside the public folder.
- Define the cache and store the app shell during installation by pasting this code:
const CACHE_NAME = "pocket-orders-v1";
const APP_SHELL = [
"/",
"/manifest.webmanifest",
"/icon-192.svg",
"/icon-512.svg"
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
);
});
What does this code cache?
- The cache name identifies this version of the Pocket Orders offline files.
- The app shell lists the catalogue entry page, manifest, and icons.
- The install listener waits until those files have entered browser storage.
- Save public/sw.js.
- Confirm sw.js appears inside the public folder.
Service worker file missing?
Check that sw.js sits beside the manifest and icon files. The production registration expects it at the site root.
Ask for help checking the location:
- Add the cache cleanup listener below the install listener by pasting this code:
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
});
Why remove old caches?
A future cache name represents a new app-shell version. The activation listener removes previous Pocket Orders caches so stale files do not accumulate.
- Save public/sw.js.
- Load http://localhost:5173/sw.js in the browser from earlier.
You should see the cache constants plus the install and activate listeners as a served JavaScript file.
Service worker URL unavailable?
Confirm the local Vite server from earlier is still running. Also check that the filename is exactly sw.js.
Ask for help with the served file:
- Add the request-handling listener below the activate listener by pasting this code:
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) return cachedResponse;
return fetch(event.request)
.then((networkResponse) => {
if (networkResponse.status === 200) {
const responseCopy = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseCopy);
});
}
return networkResponse;
})
.catch(() => {
if (event.request.mode === "navigate") {
return caches.match("/");
}
return Response.error();
});
})
);
});
How are requests handled?
- Only read-only requests enter this caching flow.
- A matching cached response returns immediately.
- A successful network response enters the cache for a later visit.
- A failed page navigation falls back to the cached catalogue entry page.
- Save public/sw.js.
- Refresh http://localhost:5173/sw.js in the browser.
You should now see install, activate, and fetch listeners in the served file.
Fetch listener missing?
Confirm you pasted the listener after the closing characters of the activate listener. Save the file before refreshing its browser URL.
Ask for help with the listener boundary:
The worker should control production builds only. This keeps local development refreshes separate from offline caching behaviour.
- Switch back to src/main.js from earlier.
- Find the online and offline event listeners near the bottom of the file.
- Add this production-only registration directly below those listeners:
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(console.error);
});
}
Why register after page load?
The browser waits for the page load before registering the worker. The production check prevents the service worker from controlling the local development app.
- Save src/main.js.
- Return to http://localhost:5173 in the browser.
You should still see all six products and the Online badge. This confirms the production gate has not disrupted the local catalogue.
Catalogue stopped loading?
Check that the registration sits outside the online and offline listener calls. Confirm that every parenthesis and brace matches the shown code.
Ask for help with the registration:
Your PWA files are now connected. Use these tabs to compare every file changed in this part.
✔️ Awesome, I've got everything!
Your manifest, icons, page links, service worker, and registration are ready for the production build.
ⓧ I'd like to double check the full code
- Compare each file below with the matching file in your VS Code workspace.
{
"id": "/",
"name": "Pocket Orders",
"short_name": "Pocket Orders",
"description": "A mobile catalogue and order app for a small shop.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f6f3ec",
"theme_color": "#1b5e4b",
"icons": [
{
"src": "/icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
Manifest checkpoint
This complete file contains the installation identity, standalone display settings, colours, and all three icon entries.
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 512 512" role="img" aria-labelledby="title">
<title id="title">Pocket Orders shopping bag</title>
<rect width="512" height="512" rx="112" fill="#1b5e4b"/>
<path d="M128 188h256l-24 188H152z" fill="#f6f3ec"/>
<path d="M184 198c0-44 32-78 72-78s72 34 72 78" fill="none" stroke="#f6f3ec" stroke-width="28" stroke-linecap="round"/>
<circle cx="220" cy="276" r="18" fill="#d98f39"/>
<circle cx="292" cy="276" r="18" fill="#d98f39"/>
<path d="M214 326c24 18 60 18 84 0" fill="none" stroke="#1b5e4b" stroke-width="16" stroke-linecap="round"/>
</svg>
Small icon checkpoint
This file provides the green shopping-bag icon at the smaller declared size.
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-labelledby="title">
<title id="title">Pocket Orders shopping bag</title>
<rect width="512" height="512" rx="112" fill="#1b5e4b"/>
<path d="M128 188h256l-24 188H152z" fill="#f6f3ec"/>
<path d="M184 198c0-44 32-78 72-78s72 34 72 78" fill="none" stroke="#f6f3ec" stroke-width="28" stroke-linecap="round"/>
<circle cx="220" cy="276" r="18" fill="#d98f39"/>
<circle cx="292" cy="276" r="18" fill="#d98f39"/>
<path d="M214 326c24 18 60 18 84 0" fill="none" stroke="#1b5e4b" stroke-width="16" stroke-linecap="round"/>
</svg>
Large icon checkpoint
This file provides the same artwork at the larger declared size used by the general and maskable manifest entries.
const CACHE_NAME = "pocket-orders-v1";
const APP_SHELL = [
"/",
"/manifest.webmanifest",
"/icon-192.svg",
"/icon-512.svg"
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) return cachedResponse;
return fetch(event.request)
.then((networkResponse) => {
if (networkResponse.status === 200) {
const responseCopy = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseCopy);
});
}
return networkResponse;
})
.catch(() => {
if (event.request.mode === "navigate") {
return caches.match("/");
}
return Response.error();
});
})
);
});
Service worker checkpoint
This complete worker caches the app shell, removes stale caches, stores successful responses, and supplies the cached catalogue during failed page navigations.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1b5e4b" />
<meta name="description" content="A mobile catalogue and order app for a small shop." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon-192.svg" type="image/svg+xml" />
<title>Catalogue</title>
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Mobile catalogue</p>
<h1>Pocket Orders</h1>
</div>
<span id="network-status" class="network-status" role="status">Checking connection</span>
</header>
<main class="layout">
<section class="catalogue" aria-labelledby="catalogue-title">
<div class="hero">
<p class="eyebrow">Fresh picks</p>
<h2 id="catalogue-title">Build your order</h2>
<p>Tap a product to add it to your cart.</p>
</div>
<div id="product-grid" class="product-grid"></div>
</section>
<aside class="cart-panel" aria-labelledby="cart-title">
<div class="cart-heading">
<h2 id="cart-title">Your order</h2>
<span id="cart-count" class="cart-count">0</span>
</div>
<div id="cart-items" class="cart-items"></div>
<div class="cart-total">
<span>Total</span>
<strong id="cart-total">$0.00</strong>
</div>
<form id="order-form" class="order-form">
<label for="customer-name">Customer name</label>
<input id="customer-name" name="customerName" minlength="2" maxlength="60" required />
<label for="customer-phone">Phone number</label>
<input id="customer-phone" name="phone" type="tel" minlength="7" maxlength="20" required />
<button id="submit-order" class="primary-button" type="submit">Place order</button>
<p id="form-message" class="form-message" aria-live="polite"></p>
</form>
</aside>
</main>
<footer>Demo catalogue. Use fake customer details while learning.</footer>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Page checkpoint
This complete page links the manifest and icon before rendering the existing catalogue, cart, form, and module entry.
import { addDoc, collection, serverTimestamp } from "firebase/firestore";
import "./style.css";
import { db } from "./firebase.js";
const PRODUCTS = [
{ id: "coffee", name: "House Coffee", category: "Drinks", price: 8.5, icon: "☕", description: "Smooth roast with a cocoa finish." },
{ id: "tea", name: "Citrus Tea", category: "Drinks", price: 6.25, icon: "🍵", description: "Bright tea with orange and lemon." },
{ id: "bread", name: "Country Loaf", category: "Bakery", price: 5.75, icon: "🍞", description: "Crusty loaf baked for sharing." },
{ id: "cookies", name: "Cookie Box", category: "Bakery", price: 9, icon: "🍪", description: "Six soft chocolate cookies." },
{ id: "jam", name: "Berry Jam", category: "Pantry", price: 7.4, icon: "🫐", description: "Small-batch mixed berry spread." },
{ id: "honey", name: "Wildflower Honey", category: "Pantry", price: 11.2, icon: "🍯", description: "Golden honey with floral notes." }
];
const cart = new Map();
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
});
const productGrid = document.querySelector("#product-grid");
const cartItems = document.querySelector("#cart-items");
const cartCount = document.querySelector("#cart-count");
const cartTotal = document.querySelector("#cart-total");
const orderForm = document.querySelector("#order-form");
const submitButton = document.querySelector("#submit-order");
const formMessage = document.querySelector("#form-message");
const networkStatus = document.querySelector("#network-status");
function renderProducts(productList = PRODUCTS) {
productGrid.innerHTML = "";
if (productList.length === 0) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "No products match your search.";
productGrid.append(empty);
return;
}
productGrid.innerHTML = productList
.map(
(product) => `
<article class="product-card">
<span class="product-icon" aria-hidden="true">${product.icon}</span>
<p class="product-category">${product.category}</p>
<h3>${product.name}</h3>
<p>${product.description}</p>
<div class="product-action">
<strong>${money.format(product.price)}</strong>
<button type="button" data-product-id="${product.id}" aria-label="Add ${product.name} to order">Add</button>
</div>
</article>
`
)
.join("");
}
function addToCart(productId) {
const product = PRODUCTS.find((item) => item.id === productId);
if (!product) return;
const existing = cart.get(productId);
cart.set(productId, {
...product,
quantity: existing ? existing.quantity + 1 : 1
});
renderCart();
}
function removeFromCart(productId) {
cart.delete(productId);
renderCart();
}
function getCartTotal() {
return Array.from(cart.values()).reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
function renderCart() {
const items = Array.from(cart.values());
const count = items.reduce((sum, item) => sum + item.quantity, 0);
cartCount.textContent = count;
cartTotal.textContent = money.format(getCartTotal());
submitButton.disabled = items.length === 0;
if (items.length === 0) {
cartItems.innerHTML = '<p class="empty-state">Your cart is ready for its first item.</p>';
return;
}
cartItems.innerHTML = items
.map(
(item) => `
<div class="cart-line">
<div>
<strong>${item.name}</strong>
<p>${item.quantity} × ${money.format(item.price)}</p>
</div>
<button type="button" class="text-button" data-remove-id="${item.id}">Remove</button>
</div>
`
)
.join("");
}
function updateNetworkStatus() {
const online = navigator.onLine;
networkStatus.textContent = online ? "Online" : "Offline: browsing only";
networkStatus.classList.toggle("offline", !online);
}
productGrid.addEventListener("click", (event) => {
const button = event.target.closest("[data-product-id]");
if (button) addToCart(button.dataset.productId);
});
cartItems.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-id]");
if (button) removeFromCart(button.dataset.removeId);
});
orderForm.addEventListener("submit", async (event) => {
event.preventDefault();
formMessage.textContent = "";
if (!navigator.onLine) {
formMessage.textContent = "Reconnect to submit this order.";
return;
}
const items = Array.from(cart.values());
if (items.length === 0) return;
const formData = new FormData(orderForm);
const order = {
customerName: formData.get("customerName").trim(),
phone: formData.get("phone").trim(),
items: items.map(({ id, name, price, quantity }) => ({ id, name, price, quantity })),
total: Number(getCartTotal().toFixed(2)),
status: "new",
createdAt: serverTimestamp()
};
submitButton.disabled = true;
submitButton.textContent = "Sending...";
try {
const documentReference = await addDoc(collection(db, "orders"), order);
const shortId = documentReference.id.slice(0, 8).toUpperCase();
formMessage.textContent = `Order ${shortId} received.`;
cart.clear();
orderForm.reset();
renderCart();
} catch (error) {
console.error(error);
formMessage.textContent = "Order not sent. Check the connection and Firebase setup.";
} finally {
submitButton.textContent = "Place order";
submitButton.disabled = cart.size === 0;
}
});
window.addEventListener("online", updateNetworkStatus);
window.addEventListener("offline", updateNetworkStatus);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(console.error);
});
}
renderProducts();
renderCart();
updateNetworkStatus();
Application checkpoint
This complete module preserves the catalogue, cart, network status, and protected order flow. The production-only registration near the bottom adds offline app-shell support.
Build and deploy the production app
Vite turns the source workspace into production files inside dist. The Hosting configuration from earlier already points Firebase at that directory.
Before you build, which new public files do you expect to find in the production output?
- Create the production build in the VS Code terminal by running:
npm run build
What does this command do?
The build script runs Vite in production mode. Vite bundles the application and copies the public PWA files into dist.
- Confirm the command completes without an error.
- Confirm a dist folder appears in the Explorer sidebar.
- Expand dist to verify that the manifest, icons, and service worker are present.
The production bundle is ready. Your PWA assets now sit beside the generated application files in dist.
Build failed?
Check the first reported filename in the terminal. A missing comma in the manifest or an unmatched brace in the service worker can stop the build.
Ask for help with the output:
Deployment publishes the exact contents of dist to an HTTPS address. That secure address allows the production service worker to control the installed app.
- Deploy the production files from the VS Code terminal by running:
firebase deploy --only hosting
What does this deployment do?
Firebase uploads the directory selected by the Hosting configuration. The command returns the HTTPS address for the live Pocket Orders site.
- Wait for the Hosting deployment to finish.
- Record the returned HTTPS address here: https://PROJECT_ID.web.app.
- Open https://PROJECT_ID.web.app in your Windows browser.
You should see the Pocket Orders catalogue at the HTTPS Hosting address. The network badge should show Online.
Hosting deployment failed?
Confirm the terminal is still inside the pocket-orders folder. Also confirm that the selected Firebase project is the project containing the Pocket Orders Web app.
Ask for help with the deployment output:
Install and test Pocket Orders on Android
The live HTTPS page can now become an installed app. Google Chrome reads the manifest and uses the service worker for repeat offline visits.
- Return to Google Chrome on your Android phone from earlier.
- Enter https://PROJECT_ID.web.app in the address bar.
You should see the Pocket Orders header, six product cards, and the Online badge on your phone.
- Tap More in the Chrome toolbar.
- Tap Install and create shortcut.
- Tap Install.
- Return to the Android home screen.
- Tap the Pocket Orders icon.
Pocket Orders should launch in its own app window. The shopping-bag icon should appear on the home screen.
Installation option missing?
Confirm you opened the HTTPS Hosting address in Google Chrome. Refresh the page once after checking that the manifest and service worker were included in the latest deployment.
Ask for help checking installability:
The first online reload lets the service worker cache files requested by the production app. This prepares the installed catalogue for the offline check.
- Reload the installed Pocket Orders app once while the phone remains online.
- Return to the Android home screen.
- Turn off Wi-Fi from the Android quick settings.
- Turn off mobile data from the Android quick settings.
Before you launch the app offline, do you expect the catalogue shell or a browser network error?
- Tap the Pocket Orders icon on the home screen.
You should see the cached Pocket Orders catalogue. The network badge should read Offline: browsing only.
- Tap Add on the House Coffee card.
- Enter Test Customer in the customer name field.
- Enter 5550100 in the phone number field.
Before you submit, do you expect Pocket Orders to send the order without a connection?
- Tap Place order.
You should see Reconnect to submit this order. The catalogue stays available while cloud checkout remains explicitly online.
- Turn your phone connection back on before submitting any more fake orders.
Offline catalogue unavailable?
Reconnect the phone and load the installed app again. Reload it once online before repeating the offline launch.
Ask for help with the cache test:
You made it work. Pocket Orders now installs from an HTTPS address, reopens its catalogue offline, and protects order submission behind a live connection.
Secret mission
Add Instant Catalogue Search
A growing catalogue needs a faster way to surface the right product. Add instant search that matches names, categories, or descriptions while preserving the cart and protected cloud order flow.
Clean Up Your Resources
Clean Up Your Resources
Pocket Orders uses Firebase Hosting's no-cost subdomain. Cloud Firestore stays within the free quota while billing remains disabled.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The local pocket-orders project folder containing your source files and dist production build.
- One persistent Firebase project with the registered Pocket Orders Web app.
- One Cloud Firestore database containing fake orders documents and deployed create-only Security Rules.
- One Firebase Hosting deployment serving the instant-search version of Pocket Orders.
- The globally installed firebase-tools package for the Firebase CLI.
Keep everything running
No action is needed. Choose this if you are still testing Pocket Orders or extending its features.
- Keep billing disabled for the Firebase project.
- Continue using fake customer details for future test orders.
- Return to Databases & Storage in the Firebase Console.
- Select Firestore.
- Select Data.
- Remove any fake documents you no longer need from the orders collection.
Your deployed app remains available with instant search, online order submission, and repeat offline catalogue access.
Pause - I'll come back to this later
Stop Hosting traffic to take the public app offline. Your Firebase project and Firestore data remain available for your return.
- Switch back to the Visual Studio Code terminal from earlier.
- Return to the pocket-orders folder in that terminal.
- Stop the live Hosting site by running this command:
firebase hosting:disable
Firebase Hosting stops serving the app. Visitors now see Site Not Found.
The Firebase project and Firestore data remain intact.
- Redeploy the existing dist build when you are ready to continue by running this command:
firebase deploy --only hosting
Firebase Hosting publishes the existing dist build again. The instant-search version becomes available at the same Hosting URL.
Delete - I don't want to use this again
Remove the cloud project, global CLI package, and local workspace when you are finished with Pocket Orders.
Deletion Removes Every Firebase Resource
Firebase project owners can restore a deleted project within 30 days. Treat this cleanup as permanent because the Web app, Hosting site, Firestore database, rules, and fake orders leave active use together.
- Return to Project settings for your selected Firebase project.
- Select General settings.
- Select Delete project.
- Acknowledge each deletion checkbox.
- Confirm Delete project.
- Return to the Firebase project list.
The deleted project no longer appears as active. Its Web app, Hosting deployment, Firestore database, Security Rules, and fake orders are no longer available.
- Return to the Visual Studio Code terminal from earlier.
- Uninstall the global Firebase CLI package by running this command:
npm uninstall -g firebase-tools
This removes the globally installed firebase-tools package from Windows.
- Close Visual Studio Code so the workspace is no longer using pocket-orders.
- Open File Explorer through Windows search.
- Browse to the location that contains the pocket-orders folder.
- Select the pocket-orders folder.
- Press Shift+Delete to remove it permanently.
- Confirm the deletion prompt.
The pocket-orders folder no longer appears in File Explorer. That is the full cleanup done: Pocket Orders no longer has an active cloud project or local workspace.
Nice Work!
Nice Work!
You did it! Pocket Orders is now an installable Android catalogue backed by protected cloud order storage.
You've learned how to:
- Build a responsive mobile catalogue for six products. Track cart quantities. Remove line items. Validate checkout details. Calculate a live total.
- Write customer orders to Cloud Firestore. Enforce create-only Security Rules. Verify fake orders through the staff-facing Firebase console.
- Deploy Pocket Orders through Firebase Hosting. Install the PWA on Android. Reopen the cached catalogue offline.
- Secret Mission: Add instant catalogue search across product text. Preserve cart contents while the filter changes.
Ready to quiz yourself?