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 (
);
}
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" && (
{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 }))}
/>
{formatCurrency(lineTotal(selectedDrink, builder, settings))}
>
) : (
Add menu items to start taking orders.
)}
{ticket.length === 0 &&
No items in the ticket.
}
{ticket.map((line, index) => (
{line.name}
{line.size} • {line.sweetness} • {line.ice}
{line.toppings.length ? line.toppings.join(", ") : "No toppings"}
{line.notes && Note: {line.notes}
}
Line total
{formatCurrency(lineTotal({ basePrice: line.basePrice }, line, settings))}
))}
)}
{view === "queue" && (
order.status !== "completed").length} />
order.status === "new").length} />
order.status === "making").length} />
order.status === "ready").length} />
{queueStatuses.map((status) => (
{titleCase(status)}
{orders.filter((order) => order.status === status).length} orders
{orders.filter((order) => order.status === status).map((order) => (
Order #{order.number}
{order.customerName || "Walk-in"}
{formatCurrency(order.total)}
{formatTime(order.createdAt)} • {titleCase(order.paymentMethod)}
{order.lines.map((line, index) => (
• {line.name} ({line.size})
))}
{nextStatusButton(order, async (newStatus) => {
try {
const response = await updateOrderStatus(order._id, newStatus);
setOrders((current) => current.map((item) => {
return item._id === response.order._id ? response.order : item;
}));
} catch (requestError) {
setError(requestError.message);
}
})}
))}
{orders.filter((order) => order.status === status).length === 0 && (
No orders in {status}.
)}
))}
)}
{view === "menu" && (
Menu management
Drinks and pricing
Saved in Mongo
Current menu
{menu.map((drink) => (
{drink.category}
{drink.name} {formatCurrency(drink.basePrice)}
{drink.description}
))}
)}
);
}
function OptionGroup({ label, choices, value, onChange }) {
return (
{label}
{choices.map((choice) => (
))}
);
}
function SummaryCard({ label, value }) {
return (
{label}
{value}
);
}
function nextStatusButton(order, onAdvance) {
const map = {
new: "making",
making: "ready",
ready: "completed"
};
const next = map[order.status];
if (!next) {
return null;
}
return (
);
}
function toppingTotal(toppings, settings) {
return toppings.reduce((sum, toppingName) => {
const topping = settings.toppings.find((item) => item.name === toppingName);
return sum + (topping?.price ?? 0);
}, 0);
}
function sizeDelta(size, settings) {
return settings.sizeOptions.find((option) => option.name === size)?.delta ?? 0;
}
function lineTotal(drink, builder, settings) {
return drink.basePrice + sizeDelta(builder.size, settings) + toppingTotal(builder.toppings, settings);
}
function ticketTotal(ticket, settings) {
return ticket.reduce((sum, line) => sum + lineTotal({ basePrice: line.basePrice }, line, settings), 0);
}
function formatCurrency(value) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
}).format(value);
}
function formatTime(value) {
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit"
}).format(new Date(value));
}
function titleCase(value) {
return value.charAt(0).toUpperCase() + value.slice(1);
}