Restructure customer order flow around hidden cart
This commit is contained in:
+80
-27
@@ -14,8 +14,8 @@ import heroCup from "../../assets/ranaze-bubble-tea-single-cup-transparent.png";
|
|||||||
const queueStatuses = ["new", "making", "ready"];
|
const queueStatuses = ["new", "making", "ready"];
|
||||||
const orderIdleTimeoutMs = 90_000;
|
const orderIdleTimeoutMs = 90_000;
|
||||||
const adminUnlockPressMs = 3_000;
|
const adminUnlockPressMs = 3_000;
|
||||||
|
const adminPassword = "admin";
|
||||||
const tabLabels = {
|
const tabLabels = {
|
||||||
order: "Order",
|
|
||||||
queue: "Order Status",
|
queue: "Order Status",
|
||||||
menu: "Menu Admin"
|
menu: "Menu Admin"
|
||||||
};
|
};
|
||||||
@@ -31,6 +31,7 @@ export default function App() {
|
|||||||
const adminPressTimerRef = useRef(null);
|
const adminPressTimerRef = useRef(null);
|
||||||
const [view, setView] = useState("order");
|
const [view, setView] = useState("order");
|
||||||
const [orderMode, setOrderMode] = useState(null);
|
const [orderMode, setOrderMode] = useState(null);
|
||||||
|
const [showCart, setShowCart] = useState(false);
|
||||||
const [adminUnlocked, setAdminUnlocked] = useState(false);
|
const [adminUnlocked, setAdminUnlocked] = useState(false);
|
||||||
const [logoPressing, setLogoPressing] = useState(false);
|
const [logoPressing, setLogoPressing] = useState(false);
|
||||||
const [menu, setMenu] = useState([]);
|
const [menu, setMenu] = useState([]);
|
||||||
@@ -90,6 +91,12 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [view, orderMode]);
|
}, [view, orderMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderMode) {
|
||||||
|
setShowCart(false);
|
||||||
|
}
|
||||||
|
}, [orderMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return;
|
return;
|
||||||
@@ -149,7 +156,7 @@ export default function App() {
|
|||||||
return selectedCategory === "All" || item.category === selectedCategory;
|
return selectedCategory === "All" || item.category === selectedCategory;
|
||||||
});
|
});
|
||||||
const selectedDrink = menu.find((item) => item._id === selectedDrinkId) || null;
|
const selectedDrink = menu.find((item) => item._id === selectedDrinkId) || null;
|
||||||
const visibleTabs = adminUnlocked ? ["order", "queue", "menu"] : ["order"];
|
const visibleTabs = adminUnlocked ? ["queue", "menu"] : [];
|
||||||
const modeDetails = {
|
const modeDetails = {
|
||||||
premade: {
|
premade: {
|
||||||
stepLabel: "House favorite",
|
stepLabel: "House favorite",
|
||||||
@@ -176,7 +183,17 @@ export default function App() {
|
|||||||
window.clearTimeout(adminPressTimerRef.current);
|
window.clearTimeout(adminPressTimerRef.current);
|
||||||
setLogoPressing(true);
|
setLogoPressing(true);
|
||||||
adminPressTimerRef.current = window.setTimeout(() => {
|
adminPressTimerRef.current = window.setTimeout(() => {
|
||||||
setAdminUnlocked((current) => !current);
|
if (adminUnlocked) {
|
||||||
|
setAdminUnlocked(false);
|
||||||
|
setView("order");
|
||||||
|
} else {
|
||||||
|
const password = window.prompt("Enter admin password");
|
||||||
|
if (password === adminPassword) {
|
||||||
|
setAdminUnlocked(true);
|
||||||
|
} else if (password !== null && password !== "") {
|
||||||
|
window.alert("Incorrect password");
|
||||||
|
}
|
||||||
|
}
|
||||||
setLogoPressing(false);
|
setLogoPressing(false);
|
||||||
}, adminUnlockPressMs);
|
}, adminUnlockPressMs);
|
||||||
}
|
}
|
||||||
@@ -186,6 +203,18 @@ export default function App() {
|
|||||||
setLogoPressing(false);
|
setLogoPressing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addLineToTicket(drink, lineBuilder) {
|
||||||
|
setTicket((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
menuItemId: drink._id,
|
||||||
|
name: drink.name,
|
||||||
|
basePrice: drink.basePrice,
|
||||||
|
...lineBuilder
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
@@ -209,6 +238,7 @@ export default function App() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{adminUnlocked && (
|
||||||
<nav className="tabs">
|
<nav className="tabs">
|
||||||
{visibleTabs.map((tab) => (
|
{visibleTabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -219,25 +249,40 @@ export default function App() {
|
|||||||
{tabLabels[tab]}
|
{tabLabels[tab]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{adminUnlocked && (
|
<button
|
||||||
<button className="tab admin-lock-btn" onClick={() => setAdminUnlocked(false)}>
|
className="tab admin-lock-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setAdminUnlocked(false);
|
||||||
|
setView("order");
|
||||||
|
}}
|
||||||
|
>
|
||||||
Hide Admin
|
Hide Admin
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{view === "order" && (
|
{view === "order" && (
|
||||||
<section className="screen">
|
<section className={`screen order-screen ${orderMode === "premade" || showCart ? "is-single" : ""}`}>
|
||||||
{orderMode && (
|
{orderMode && (
|
||||||
<div className="order-toolbar">
|
<div className="order-toolbar">
|
||||||
<button className="text-btn" onClick={() => setOrderMode(null)}>
|
<button className="text-btn" onClick={() => {
|
||||||
|
setOrderMode(null);
|
||||||
|
setShowCart(false);
|
||||||
|
}}>
|
||||||
Back to drink options
|
Back to drink options
|
||||||
</button>
|
</button>
|
||||||
|
<div className="order-toolbar-actions">
|
||||||
|
{ticket.length > 0 && (
|
||||||
|
<button className="primary-btn subtle-btn" onClick={() => setShowCart(true)}>
|
||||||
|
View Order ({ticket.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<p className="muted order-toolbar-note">
|
<p className="muted order-toolbar-note">
|
||||||
This screen returns to the drink menu after 90 seconds of inactivity.
|
This screen returns to the drink menu after 90 seconds of inactivity.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!orderMode && (
|
{!orderMode && (
|
||||||
@@ -276,7 +321,7 @@ export default function App() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{orderMode && (
|
{orderMode && !showCart && (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -286,7 +331,9 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="builder-header-actions">
|
<div className="builder-header-actions">
|
||||||
<button className="text-btn" onClick={() => setOrderMode(null)}>Change order style</button>
|
<button className="text-btn" onClick={() => setOrderMode(null)}>Change order style</button>
|
||||||
<div className="ticket-total">{formatCurrency(ticketTotal(ticket, settings))}</div>
|
{ticket.length > 0 && (
|
||||||
|
<button className="text-btn" onClick={() => setShowCart(true)}>Go to order</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chip-row">
|
<div className="chip-row">
|
||||||
@@ -306,6 +353,10 @@ export default function App() {
|
|||||||
key={drink._id}
|
key={drink._id}
|
||||||
className={`drink-card ${selectedDrinkId === drink._id ? "is-selected" : ""}`}
|
className={`drink-card ${selectedDrinkId === drink._id ? "is-selected" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (orderMode === "premade") {
|
||||||
|
addLineToTicket(drink, initialBuilder(settings));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSelectedDrinkId(drink._id);
|
setSelectedDrinkId(drink._id);
|
||||||
setBuilder(initialBuilder(settings));
|
setBuilder(initialBuilder(settings));
|
||||||
}}
|
}}
|
||||||
@@ -322,6 +373,16 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{orderMode === "premade" && (
|
||||||
|
<div className="empty-state mode-note">
|
||||||
|
Tap any drink to add it to your order. Use the order button whenever you are ready to review.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orderMode && !showCart && orderMode !== "premade" && (
|
||||||
|
<div className="panel">
|
||||||
{selectedDrink && builder ? (
|
{selectedDrink && builder ? (
|
||||||
<div className="inline-builder">
|
<div className="inline-builder">
|
||||||
<div className="inline-builder-header">
|
<div className="inline-builder-header">
|
||||||
@@ -398,24 +459,11 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{orderMode === "premade" && (
|
|
||||||
<div className="empty-state mode-note">
|
|
||||||
This option keeps the drink exactly as our signature recipe.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="builder-actions">
|
<div className="builder-actions">
|
||||||
<button
|
<button
|
||||||
className="primary-btn"
|
className="primary-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTicket((current) => [
|
addLineToTicket(selectedDrink, builder);
|
||||||
...current,
|
|
||||||
{
|
|
||||||
menuItemId: selectedDrink._id,
|
|
||||||
name: selectedDrink.name,
|
|
||||||
basePrice: selectedDrink.basePrice,
|
|
||||||
...builder
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
setBuilder(initialBuilder(settings));
|
setBuilder(initialBuilder(settings));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -424,21 +472,24 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state">Add menu items to start taking orders.</div>
|
<div className="empty-state">Choose a drink to customize it before adding it to your order.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{orderMode && (
|
{orderMode && showCart && (
|
||||||
<div className="panel">
|
<div className="panel cart-panel">
|
||||||
<section className="ticket">
|
<section className="ticket">
|
||||||
<div className="ticket-header">
|
<div className="ticket-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Your order</p>
|
<p className="eyebrow">Your order</p>
|
||||||
<h3>Review your drinks</h3>
|
<h3>Review your drinks</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ticket-header-actions">
|
||||||
|
<button className="text-btn" onClick={() => setShowCart(false)}>Add more drinks</button>
|
||||||
<button className="text-btn" onClick={() => setTicket([])}>Clear all</button>
|
<button className="text-btn" onClick={() => setTicket([])}>Clear all</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="ticket-lines">
|
<div className="ticket-lines">
|
||||||
{ticket.length === 0 && <div className="empty-state">Your order is empty.</div>}
|
{ticket.length === 0 && <div className="empty-state">Your order is empty.</div>}
|
||||||
@@ -502,6 +553,8 @@ export default function App() {
|
|||||||
setTicket([]);
|
setTicket([]);
|
||||||
setCustomerName("");
|
setCustomerName("");
|
||||||
setPaymentMethod("cash");
|
setPaymentMethod("cash");
|
||||||
|
setShowCart(false);
|
||||||
|
setOrderMode(null);
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(requestError.message);
|
setError(requestError.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ button {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-screen.is-single {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.order-toolbar {
|
.order-toolbar {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -183,6 +187,14 @@ button {
|
|||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-toolbar-actions,
|
||||||
|
.ticket-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-screen,
|
.queue-screen,
|
||||||
.menu-screen {
|
.menu-screen {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -341,6 +353,11 @@ button {
|
|||||||
box-shadow: 0 12px 24px rgba(223, 162, 50, 0.28);
|
box-shadow: 0 12px 24px rgba(223, 162, 50, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subtle-btn {
|
||||||
|
box-shadow: none;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.primary-btn:disabled {
|
.primary-btn:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -458,6 +475,12 @@ select {
|
|||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-panel .ticket {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ticket-line header {
|
.ticket-line header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -574,6 +597,12 @@ select {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-toolbar-actions,
|
||||||
|
.ticket-header-actions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-builder-header {
|
.inline-builder-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user