The online shop system exists because a local seller running a shop out of a chat thread eventually loses an order. What replaces the thread is deliberately unglamorous: a catalogue with search and category filters, a product page that shows stock before the tap, a cart that is never ambiguous about which shop it belongs to, and an order that moves through five named states with a notification on every transition.
Product pages: variants, stock and a legible discount, before the tap
Each product carries photos, a description, stock, and a price that can be discounted — a sale price shown next to the struck-through original with a percentage badge, so the size of the discount is legible rather than implied. When a product is out of stock, that is drawn over the photo, not discovered at checkout. This follows a rule the whole app is built on: anything that changes what a tap does has to be visible before the tap. A button must never promise something the next screen refuses.
Variants are generated from attributes rather than entered one by one. The seller declares the axes — size, colour, whatever the product actually varies by — and the combinations become variants that each hold their own stock. That keeps the common failure out of the data: three separately typed listings for the same shirt that can never be reported on together, and a stock count that is right for one of them.
The cart belongs to one shop
The cart is single-shop. Adding an item from a different seller asks before clearing what is already there, rather than quietly building a basket that spans four businesses. This is a design refusal, not a missing feature: a multi-shop cart implies one checkout, one shipping calculation and one point of responsibility when something does not arrive, and none of those exist when the sellers are four independent local businesses with four delivery arrangements.
One shop per cart, on purpose. Switching shops asks to clear the cart. A basket that spans several sellers would need a single checkout to be honest, and there is no marketplace-wide checkout here — each order is between a buyer and one business.
The shipping fee is not a field the app sends. It is read out of the shop's own record on the server when the order is placed, and it applies only to delivery.
Checkout: delivery or pickup, and a fee the client cannot set
Checkout asks the questions an order actually needs: delivery or pickup, name, phone, address, a note, and a payment choice. Choosing pickup removes the shipping fee entirely rather than showing it as zero, because those two states mean different things and a zero reads as a bug. Addresses come from an address book with a default, so the second order does not require retyping the first one's address — and it is one address book, shared with the profile, because a profile address and a delivery address are the same fact and storing it twice guarantees the two copies eventually disagree.
The fee itself is server-side. place_shop_order reads businesses.delivery_fee from the database and ignores whatever value the client submitted, exactly as the dine-in path prices every line itself. A trusted client field is a self-service discount; the server owns money. This is not a policy decision that can be turned off in settings — it is where the number comes from.
Five order states, a tracking link, and both sides notified
An order moves through pending, confirmed, packing, shipped and delivered, with cancelled and rejected as terminal branches. The buyer sees a progress bar rather than a status word in isolation, so "packing" reads as a position in a sequence with something after it. A tracking number is attached at the shipping step, alongside a carrier chosen from an admin-curated list that carries a tracking-URL template — so the number the shop types becomes a link the buyer can open. A shop cannot attach a tracking number without naming a carrier, because a number with no carrier can never become a link.
Both endings are given a reason. A customer cancelling has to say why, and a shop rejecting has to say why, and the reason travels with the order instead of being a message that scrolls away in a chat. Every transition notifies both sides, because an ordering flow with no notifications leaves shops missing orders and buyers guessing.
- pending — placed, waiting for the shop
- confirmed — accepted, the shop is committed
- packing — being prepared
- shipped — out, with a tracking number attached
- delivered — closed, and the buyer becomes eligible to review
The seller's side: point of sale, online orders and stock
A retail business gets three business console tiles: a point of sale for in-person selling, an online-orders console for everything that came through the app, and products and stock with low-stock warnings. The dashboard carries live badges refreshed every few seconds, so a new order announces itself instead of waiting to be found. Selling in the shop and selling through the app write to the same ledger. None of those tiles carry a monthly fee; what a shop package and the add-on plugins each include is set out on the plans page. If you are still deciding which POS system to use, there is a checklist of what to compare, the four bills a till really costs, and a page written for grocery shops.
Because both channels land in one place, reports cover the whole business rather than the online slice: revenue, order count, average ticket, a split across cash, PromptPay and other, the top five best sellers, a seven-day revenue bar chart, and periods of today, seven days, thirty days or all time. Receipts can be reprinted and a shareable receipt text generated from the same record; a tax invoice cannot be issued, because only a VAT registrant may issue one and nothing in this system records any shop's registration yet. Equipment the shop owns rather than sells — the fridge, the scales, the delivery bike — belongs in the asset register instead, which is a separate tile from stock. The customer list is built from that ledger too — with per-customer notes and tags, purchase history, loyalty points earned at a rate the shop sets in its own currency, against a configurable minimum sale and reward.
Staff access is governed by a permission matrix with role presets and per-person overrides, so a shop assistant can be given products and orders without payroll or reports. The same matrix exists in the database as has_capability(business_id, capability), which expands the presets server-side for the row-level security policies — the server never takes the client's word for what role someone holds.
Storefront hours and the online shop run on separate clocks
A business's listed opening hours on its public page describe the physical location — when the counter is staffed, when the sign says open. Placing an order through the shop page is not gated by them: place_shop_order has no opening-hours check at all, so the online shop keeps taking orders around the clock even on a day the storefront is closed. That is a deliberate split rather than an oversight — a retail seller fulfilling online orders from a home workshop, or a shop whose counter closes at 8pm but whose page should still take tomorrow's order at midnight, both need the online side to stay open independently of the physical one.
Product reviews only a real buyer can write
Product reviews are separate from the business review, and both are gated the same way: only a customer the platform can prove transacted may write one. Eligibility is a database view, and buying is one of the qualifying kinds alongside eating in, ordering delivery, staying and using a service. Everyone else sees a lock card that explains why there is no write button rather than a button that fails.
The star rating is not a stored number anyone edits. A database trigger recomputes it from the review rows, so the rating on the shop card and the reviews on the page cannot drift apart. A buyer can edit their latest review, delete it, or add a new one on a repeat purchase while the old one stays; the shop can reply publicly.