commit 398df1d2e3614f8571ec558c7fc7f26ef24355c1 Author: Zeeshaun Date: Tue May 5 15:47:17 2026 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29d7aaf --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +client/dist +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..c55c392 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Ranaze POS + +Self-hosted tablet-first POS for a boba shop. + +## Stack + +- Frontend: `React` + `Vite` +- Backend: `Express` +- Database: self-hosted `MongoDB` via `Mongoose` + +## Features + +- Customer order builder for drinks, size, sweetness, ice, toppings, and notes +- Ticket flow with cash, tab, and pay-later placeholders +- Live order queue with `new`, `making`, `ready`, and `completed` statuses +- Menu management persisted in MongoDB +- Seeded default drinks on first launch + +## Project layout + +- `client/`: React tablet UI +- `server/`: Express API and Mongo models + +## Environment + +Create `.env` in the repo root: + +```env +MONGODB_URI=mongodb://localhost:27017/ranaze-pos +PORT=4000 +``` + +## Install + +```bash +npm install +``` + +## Run in development + +Terminal 1: + +```bash +npm run dev:server +``` + +Terminal 2: + +```bash +npm run dev:client +``` + +Open `http://localhost:5173`. + +## Build and run + +```bash +npm run build +npm start +``` + +The production server serves the built frontend from `client/dist`. + +## What is still missing + +- Credit card processing +- Receipt and label printing +- Employee authentication and permissions +- Multi-location inventory tracking +- Hardware integrations for cash drawer and scanners diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..99b0512 --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + Ranaze POS + + +
+ + + diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..b260d35 --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,606 @@ +import { useEffect, useState } from "react"; +import { + createMenuItem, + createOrder, + deleteMenuItem, + deleteOrder, + fetchBootstrap, + resetMenu, + updateOrderStatus +} from "./api.js"; + +const queueStatuses = ["new", "making", "ready"]; + +const initialBuilder = (settings) => ({ + size: settings.sizeOptions[0]?.name ?? "Regular", + sweetness: settings.sweetnessOptions[3] ?? settings.sweetnessOptions[0] ?? "100%", + ice: settings.iceOptions[2] ?? settings.iceOptions[0] ?? "Regular Ice", + toppings: [], + notes: "" +}); + +export default function App() { + const [view, setView] = useState("order"); + const [menu, setMenu] = useState([]); + const [orders, setOrders] = useState([]); + const [settings, setSettings] = useState(null); + const [selectedCategory, setSelectedCategory] = useState("All"); + const [selectedDrinkId, setSelectedDrinkId] = useState(null); + const [builder, setBuilder] = useState(null); + const [ticket, setTicket] = useState([]); + const [customerName, setCustomerName] = useState(""); + const [paymentMethod, setPaymentMethod] = useState("cash"); + const [menuForm, setMenuForm] = useState({ + name: "", + category: "", + description: "", + price: "" + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + loadBootstrap(); + }, []); + + useEffect(() => { + if (!settings) { + return; + } + + const visibleMenu = menu.filter((item) => { + return selectedCategory === "All" || item.category === selectedCategory; + }); + const selectedExists = visibleMenu.some((item) => item._id === selectedDrinkId); + const fallbackDrink = visibleMenu[0] || menu[0] || null; + + if (!selectedExists && fallbackDrink) { + setSelectedDrinkId(fallbackDrink._id); + setBuilder(initialBuilder(settings)); + return; + } + + if (!builder && fallbackDrink) { + setBuilder(initialBuilder(settings)); + } + }, [menu, selectedCategory, selectedDrinkId, settings, builder]); + + async function loadBootstrap() { + try { + setLoading(true); + setError(""); + const data = await fetchBootstrap(); + setMenu(data.menu); + setOrders(data.orders); + setSettings(data.settings); + setSelectedDrinkId(data.menu[0]?._id ?? null); + if (data.settings) { + setBuilder(initialBuilder(data.settings)); + } + } catch (loadError) { + setError(loadError.message); + } finally { + setLoading(false); + } + } + + if (loading) { + return
Loading POS...
; + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + const categories = ["All", ...new Set(menu.map((item) => item.category))]; + const visibleMenu = menu.filter((item) => { + return selectedCategory === "All" || item.category === selectedCategory; + }); + const selectedDrink = menu.find((item) => item._id === selectedDrinkId) || null; + + return ( +
+
+
+

Self-hosted boba POS

+

Ranaze

+
+ +
+ + {view === "order" && ( +
+
+
+
+

Step 1

+

Choose drinks

+
+
+ {categories.map((category) => ( + + ))} +
+
+
+ {visibleMenu.map((drink) => ( + + ))} +
+
+ +
+
+
+

Step 2

+

{selectedDrink?.name || "Select a drink"}

+
+
{formatCurrency(ticketTotal(ticket, settings))}
+
+ + {selectedDrink && builder ? ( + <> + option.name)} + value={builder.size} + onChange={(size) => setBuilder((current) => ({ ...current, size }))} + /> + setBuilder((current) => ({ ...current, sweetness }))} + /> + setBuilder((current) => ({ ...current, ice }))} + /> +
+ Toppings +
+ {settings.toppings.map((topping) => { + const checked = builder.toppings.includes(topping.name); + return ( + + ); + })} +
+
+