One screen. Admin on the left, store in the middle, cart on the right. A real, working e-commerce flow, no framework, no database server. Just HTML, CSS and JavaScript with localStorage as the backend. Scroll up to the demo and push it around, add products, place an order. It all persists.

In the previous eight tutorials we built the pieces: a product card, a cart badge, a responsive grid, a filter, a slide-out drawer, a quantity input, a checkout form. Useful, but scattered. Today we wire them together into the real thing: a complete storefront with a live admin panel that any shop owner could use to sell.

What makes this one screen

A shop has three roles living in one head:

  • Admin, who adds products, watches orders, checks stats.
  • Visitor, who browses, filters, adds to cart.
  • Buyer, who pays, and reads the confirmation.

Normally these hide on different pages. Here they share the same pixel budget, because tutorials are better when you can see cause and effect in one glance. Change a price on the left, refresh nothing, watch it update in the middle, see it in the cart total on the right.

Three files only

The entire app is index.html, style.css and script.js. No build step, no node_modules, no framework. You can open the HTML file locally and it just works.

Layout with CSS grid

Desktop uses a three-column grid. On tablet the cart collapses into an off-canvas drawer. On phone the admin collapses too and only opens with the hamburger. A topbar shows up only on small screens. The trick is a single grid template plus two media queries:

.layout { display: grid; grid-template-columns: 280px 1fr 320px; }

@media (max-width: 1100px) {
  .layout { grid-template-columns: 240px 1fr; }
  .pane-right { position: fixed; right: 0; width: 360px; transform: translateX(100%); }
  #app[data-side-right="1"] .pane-right { transform: translateX(0); }
}

@media (max-width: 720px) {
  .layout { grid-template-columns: 1fr; }
  .pane-left { position: fixed; left: 0; transform: translateX(-100%); }
  #app[data-side-left="1"] .pane-left { transform: translateX(0); }
}

The data-side-left and data-side-right attributes on the app root are the entire "state management" for the drawers. JavaScript flips them on button clicks. CSS does the animation. No libraries involved.

State in a single object

Everything the app knows, it keeps in one plain object: products, cart, orders, filter. On every write we save it to localStorage. On page load we restore it. That's the whole backend:

const LS_KEY = 'webstree-shop-v1'

const state = (() => {
  try {
    const raw = localStorage.getItem(LS_KEY)
    if (raw) return JSON.parse(raw)
  } catch (_) {}
  return { products: seed.slice(), cart: {}, orders: [], filter: { cat: 'all', q: '' } }
})()

function save() {
  localStorage.setItem(LS_KEY, JSON.stringify(state))
}

Every mutation calls save(). Reload the page: nothing is lost. Open a second tab: both see the same cart, because localStorage is per-origin. That's the cheapest persistent database you can imagine, and it's exactly enough for a tutorial or a tiny local app.

Admin: live product CRUD

The form on the left adds or edits a product. Click the pencil icon on an item and the form preloads with it. Click save and the changes land in the grid instantly, because every mutation renders the affected panels:

pForm.addEventListener('submit', e => {
  e.preventDefault()
  const id = $('#pId').value || uid()
  const p  = { id, name: $('#pName').value.trim(), price: +$('#pPrice').value, stock: +$('#pStock').value, cat: $('#pCat').value, emoji: $('#pEmoji').value || '📦' }
  const i  = state.products.findIndex(x => x.id === id)
  if (i >= 0) state.products[i] = p
  else state.products.push(p)
  save(); renderAdminList(); renderGrid()
})

Delete is a confirmed action that also removes the product from the cart if it was there. Small detail, big UX win: no orphaned cart items.

Store: filters + search

The grid renders from state.products filtered by category and search. Search matches case-insensitively on name. No debounce, no fancy indexing — Array.filter on a list of 8, 80 or 800 items is already instant.

function renderGrid() {
  const { q, cat } = state.filter
  const query = q.trim().toLowerCase()
  const list = state.products.filter(p => {
    if (cat !== 'all' && p.cat !== cat) return false
    if (query && !p.name.toLowerCase().includes(query)) return false
    return true
  })
  // ... render into #grid
}

Stock awareness is important. If a product is out, the button is disabled and the label turns red. If the user already has the last one in the cart, they can't add another. These tiny checks prevent a whole category of bugs, the kind that make customers email you at midnight.

Cart: qty control + totals

Cart lines use a minus / number / plus control. The number input also accepts typed values, so power users can set 12 without tapping plus twelve times. Event delegation keeps the listener count at one, no matter how many lines are on screen:

$('#cart').addEventListener('click', e => {
  const inc = e.target.closest('[data-inc]')
  const dec = e.target.closest('[data-dec]')
  if (inc) setQty(inc.dataset.inc, (state.cart[inc.dataset.inc]?.qty || 0) + 1)
  if (dec) setQty(dec.dataset.dec, (state.cart[dec.dataset.dec]?.qty || 0) - 1)
})

Totals show subtotal, shipping (free over $50), 8% tax, and a grand total. Prices use Intl.NumberFormat, so currency formatting is correct in every locale for free.

Checkout: the satisfying part

This is the part that was missing from the old "cheapest-checkout" tutorial. The form is native HTML5 with all the right autocomplete hints (cc-number, cc-exp, cc-csc), so browsers can offer to fill in saved cards. The card number field also pretty-prints digits in groups of four as you type:

coForm.querySelector('[name="card"]').addEventListener('input', e => {
  const digits = e.target.value.replace(/\D/g, '').slice(0, 19)
  e.target.value = digits.replace(/(.{4})/g, '$1 ').trim()
})

When the user clicks Pay, the button shows a spinner, the form goes read-only for a moment, and after 1.5 seconds we draw a green check that animates its circle and tick stroke:

.check circle { stroke-dasharray: 176; stroke-dashoffset: 176; animation: draw 0.5s ease-out forwards; }
.check path   { stroke-dasharray:  60; stroke-dashoffset:  60; animation: draw 0.4s 0.4s ease-out forwards; }

@keyframes draw { to { stroke-dashoffset: 0; } }

Meanwhile in JavaScript the order is saved, stock is decremented, the cart is emptied, admin stats update. The success screen shows the order id. Close it, open Orders in the admin panel, and there it is. Real database behaviour, no server.

Responsive without media-query soup

Three breakpoints is enough for almost every layout. Desktop shows all three panes. Tablet converts the cart to an off-canvas drawer. Phone converts both panes. The trick: the panes don't stop existing, they just move and get wrapped in a translate. Toggling a single data attribute shows or hides them, and a shared backdrop handles click-outside for both.

Honest pricing: mizan over tricks

A shop is trust. The Qur'an opens sûrat al-Mutaffifîn with three ayet-i kerîme about dishonest measure: "Woe to those who cheat in measure, who demand full measure when they buy, but shortchange others when they sell" (83:1–3). Twenty-first-century "dark patterns" are exactly this — drip pricing, fake urgency timers, pre-ticked add-ons, fake strike-through discounts. Easy wins, slow erosion.

The honest cart this demo builds refuses them. Four rules that cost nothing:

  • Every line has a name. No "additional services" mystery charges.
  • Free things say Free, not $0.00. It matches how people speak.
  • The total is the final number, separated by a clear divider. Nothing after the grand total changes the grand total.
  • Shipping threshold announced up front (free ≥ $50), not revealed at the last step.

Technically trivial. Ethically the whole point. Your customer's second visit — and their word-of-mouth — is paid for in this small honesty.

Accessibility basics that paid off

Every button has a meaningful label. The dialog uses <dialog>, so Escape closes it and focus is trapped automatically. The cart badge uses aria-live="polite", so screen readers hear the new total when you add something. The admin tab-buttons use role="tablist". None of these cost anything, and they all make the demo feel like a real product.

From this demo to a real shop

This is the smallest honest storefront that still feels like a product. Swap localStorage for fetch('/api/products') and the same UI becomes a real multi-user shop. Add Stripe in place of the simulated pay button. Add authentication for the admin panel. Plug in a database. Each step replaces one fake with one real, but the shape of the app stays the same.

That's the real lesson of this tutorial: a shop is not the database, the framework, the deployment setup. A shop is the flow: browse, add, check out, confirm. Everything else is plumbing. If you can make the flow feel good with 700 lines and no dependencies, you already know how to ship.

Try it yourself: scroll up to the hero, add a few baklava, open the admin panel, raise the price, watch the subtotal follow. Hit checkout, fill the form with any values, and enjoy the animation. Your order lives in your browser until you clear it.

What you just learned

  • A three-pane responsive layout from one CSS grid plus two media queries.
  • A single-object state model with localStorage as a free persistent store.
  • Event delegation for lists that grow without leaking listeners.
  • Real checkout UX: form validation, autocomplete, card prettifier, spinner, animated confirmation.
  • Respect for stock, for taxes, for shipping thresholds, for screen readers.

Eight tutorials worth of pieces, one afternoon of wiring. If you build this, you've built a shop. Bismillâh, may the sale be blessed, may your orders be real, may your customers return.