Skip to content
wannacode

The shop front

In short: A web app project: a shop front with a catalogue from an API, a product page by address, a shared cart, a checkout with checks, and “My orders”.

Problem

The shop front

The shop has an API: products, categories, orders. What is missing is the shop front the buyer shops through. The buyer's path is:

  1. Search the catalogue or pick a category.
  2. Open a product and add it to the cart.
  3. Change the quantities in the cart.
  4. Order: name, email, address. A mistake shows where it is.
  5. See the order in "My orders".

You have built every part in a lesson of its own. Here you put them together in one app and build it in five steps: every task starts from the solution of the one before.

The idea

The skeleton

This is the app at step zero: the menu and placeholder pages. Click through the menu.

Vue

The files are laid out like in a real project:

  • api.js holds every request. The pages call getProducts, placeOrder and do not know the addresses. An error from the server comes as an Error with status and errors (the errors per field).
  • format.js formats money, dates and status.
  • pages/ are the pages, one per address. components/ are the parts the pages use: ProductCard, Filter, Field.
  • store/cart is the cart, shared by the whole app.

The router is in router.js with createMemoryHistory(), and main.js puts createPinia() and router on the app. The page goes in <RouterView /> in App.vue, as in the routing lesson.

The sandbox has no address bar, so the history is kept in memory. In a real project it is createWebHistory(). The rest is the same.

Filter and Field are ready from the lessons: fields bound to state, and a field with a label and an error message. You write the pages and the cart.

Exercise

Step 1: the catalogue

The catalogue loads the products with getProducts({ q, category }) and shows them with ProductCard in a list with the class grid. The filter is on top. Every change of the search or the category starts a new request, and the old one is cancelled. While it waits, the page says "Loading…". When nothing matches, it says "No such products."

Watch for two things from the lesson on data from an API:

  • The list follows the filter. The request is in watch([q, category], …, { immediate: true }), not in onMounted.
  • The old answer does not win. If "Dairy" answers slowly and you have already picked "Drinks", the slow answer must not wipe out the new one. An AbortController cancels the old request in the onCleanup of the watch.

Write CatalogPage: the products from the API by the filter, "Loading…", "No such products." and cancelling the old request. The check searches for "bread", then picks "Dairy" and right away "Drinks" (Dairy answers in 900 ms), then searches inside a category.

Vue

Exercise

Step 2: the product page

The name in ProductCard becomes a link to /product/:id. ProductPage takes id from the address, loads the product with getProduct(id) and shows the name, category, price and stock: "In stock: 18 pcs" or "Sold out". Below it is "More from this category": up to three other products from the same category, with links.

Watch for:

  • The link belongs to the router: <RouterLink :to="…">, not <a href>. A plain link loads the whole page again. In the sandbox it goes nowhere.
  • Same page, another parameter. From "White cheese" to "Kashkaval cheese" the router does not make a new ProductPage, it only changes id. The request is in watch(() => route.params.id, …, { immediate: true }).
  • The id from the address is a string: "7", not 7. getProduct(id) puts it in the address, and that is fine. If you look it up in an array with ===, you compare a string with a number.

The details are in the routing lesson.

Make the name in ProductCard a link and write ProductPage. The check opens "White cheese", then "Kashkaval cheese" from "More from this category", goes back to the catalogue and opens the sold-out "Cheese banitsa".

Vue

Exercise

Step 3: the cart

ProductPage and the menu already call useCart(): an "Add to cart" button, "In the cart: 2" and "Cart (2)". For now useCart is a placeholder. Write the real cart and CartPage.

store/cart.js is a Pinia store made with defineStore('cart', () => { … }), as in the shared state lesson. It returns items, count, total, add, setQty, remove, clear. count and total are computed from items, not kept separately.

CartPage shows a row per product: the name, a drop-down "Quantity: White cheese" (from 1 up to the stock, at most 10), the row's sum and a "Remove" button named "Remove White cheese". Below them is "Total: …" and a "To checkout" link. An empty cart says "The cart is empty."

Write store/cart and CartPage. The check adds two white cheeses and apples, opens the cart, changes the cheese to 3 and removes the apples, watching the menu and the total.

Vue

Exercise

Step 4: the checkout

CheckoutPage has a form with three Fields: "Name", "Email", "Address". On "Place order":

  1. A check in the browser. An empty name: "Enter your name." An email without @: "Enter your email." An empty address: "Enter your address." If there is an error, no request goes out.
  2. The request. placeOrder({ name, email, address, items }), where items is [{ productId, qty }] from the cart.
  3. The answer. On 201 the page says, for example, "Order #61 for €15.80." and the cart is emptied. On 400 err.errors is an object per field, for example { email: "The email is not valid" }, and every message goes next to its field.

Field already shows error next to the field, with aria-invalid and aria-describedby. The form has novalidate, so that your messages speak, not the browser's bubbles. The check in the browser is a convenience: it catches the empty fields before a request goes out. The server has the last word, so "maria@shop" passes in the browser and the server sends it back. How to build a form is in the forms lesson.

Write CheckoutPage. The check adds two white cheeses, presses "Place order" with an empty form, then with the email "maria@shop" (the server rejects it) and last with a valid email.

Vue

Exercise

Step 5: my orders and the whole path

OrdersPage loads getOrders() every time it opens and shows the orders of customer ME, newest on top: "Order #61", the date, the status, the total and the lines ("White cheese × 3"). While it waits, "Loading…", and with no orders, "You have no orders yet."

The training API has no login and returns the orders of every customer. So the page filters by customerId === ME. A real server returns only yours.

Now the app is whole. The check walks the path from the start of the lesson: it looks at the old orders, filters "Dairy", opens the white cheese, adds 2, makes them 3 in the cart, orders with a wrong email, sees the error next to the field, fixes it, orders and finds the new order on top of "My orders".

Write OrdersPage. The check walks the buyer's whole path and at the end looks for the new order on top of "My orders".

Vue

The idea

What is simplified here

The app is real, but some things around it are simplified. In a real shop:

  • The server knows who you are. You log in with a password and the server returns only your orders, by the session. A filter in the browser protects nothing: anyone can change ME.
  • The price lives on the server. The order sends only productId and qty. The total comes back in the answer, worked out from the prices in the database.
  • The cart survives. Here it lives in memory and is gone on reload. A real one is in localStorage or on the server.
  • The addresses are real. createWebHistory() instead of the memory history, and hosting that returns index.html for every address.
More precisely, if you are curious

The two libraries. The path is the same: addresses, data from an API, a shared cart, a form with errors per field. Vue does it with reactivity: watch on the parameter and the filter, Pinia for the cart, v-model and defineModel for the fields. The other library solves the same tasks its own way.

Tests. The checks of the steps click and type like a person. You write the same with Testing Library: render, screen.getByRole, userEvent. How is in the testing lesson.

Speed. The catalogue loads on every key press. With the old request cancelled this is correct, but wasteful. In a real project the search waits for 300 ms of quiet before it goes out (debounce).

The idea

What next

Move the shop front into a real project, as in the lesson "From the sandbox to a project":

Terminal
npm create vite@latest shop -- --template vue
cd shop
npm install vue-router pinia

Copy the folders pages/, components/, store/ and the files api.js, format.js, router.js, style.css into src/. In router.js swap createMemoryHistory() for createWebHistory().

The API is no longer pretend. Run a real server with the addresses /api/products, /api/orders, or point /api at it with server.proxy in vite.config.js. Then npm run build and publish dist/ with a rule that returns index.html for every address.

More tasks

  • Order details. /orders/:id with GET /api/orders/:id and a link from "My orders". An unknown number shows "No such order."
  • A cart that survives. Keep items in localStorage and read them on start.
  • Tests. A test for CartPage: changing a quantity changes the total. A test for CheckoutPage: an empty form shows the three errors and sends no request.

Test

Check yourself

1. You pick "Dairy" and the list keeps every product. The products are loaded in onMounted. What is missing?
2. On "White cheese" you click "Kashkaval cheese" under "More from this category". The address becomes /product/8, yet the page still shows the white cheese. Why?
3. The check in the browser let "maria@shop" through, and the server answered 400 with { errors: { email: "The email is not valid" } }. What does the page do?
4. useCart is a function that makes a ref([]) on every call, without Pinia. ProductPage adds, and the menu says "Cart (0)". Why?
5. Why does the order send only productId and qty, without the prices?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·