Click & Serve Web app
Le backend
09/09/2024 - 16h14
Lancement du nouveau projet pour une application de Click & Serve. Aujourd'hui je commence à définir le projet, organisation du backend, le parcours client sur l'app etc...
Pour le stack :
- NodeJs
- Express
- MongoDB
- React
- MaterialUI
J'aurais aussi besoin de librairies React, notamment pour l'impression sur des machines PoS.
09/09/2024 - 17h20
Le lien du repository pour suivre le projet sur Github.
10/09/2024 - 10h49
Ce matin je m'occupe de :
- Connecter le back à MongoDB.
- Créer le user model (un seul utilisateur normalement
- Les routes et les controllers (register, login, logout, reset password, ...)
- Créer les items models
- Les routes des items, en priorité les GET, je vais probablement remplir la Db à la main pour que l'app soient prête plus vite.
10/09/2024 - 12h01
Je pense que l'ensemble des routes et controllers sont crées.
Pour les users :
const express = require("express");
const authController = require("../controllers/auth.controller");
const userController = require("../controllers/user.controller");
const router = express.Router();
// auth
router.post("/register", authController.signUp);
router.post("/login", authController.signIn);
router.get("/logout", authController.logout);
//forgot password
router.post("/forgot-password", authController.forgotPassword);
router.get("/reset-password/:token", authController.resetPassword);
router.put("/reset-password", authController.updatePassword);
// user db
router.get("/", userController.getAllUsers);
router.get("/:id", userController.userInfo);
router.get("/username/:username", userController.userProfil);
router.put("/:id", userController.updateUser);
router.delete("/:id", userController.deleteUser);
module.exports = router;
Pour les plats :
// routes/menuItems.js
const express = require("express");
const menuItemController = require("../controllers/menuItem.controller");
const router = express.Router();
router.get("/menu-items", menuItemController.getAllItems);
router.get("/menu-items/:id", menuItemController.getOneItem);
router.post("/menu-items", menuItemController.createItem);
router.put("/menu-items/:id", menuItemController.updateItem);
router.delete("/menu-items/:id", menuItemController.deleteItem);
module.exports = router;
En ce qui concerne les schemas, rien de très compliqué, pour les utilisateurs :
- username
- password
- role
- un resetToken avec son timestamp pour réinitialiser le mot de passe.
Pour les plats c'est un peu plus recherché, on a un principale qui englobe les caractéristiques de tous les elements, c'est à dire :
- type
- name
- description
- price
- available
- et les details
Les details auront leur propre schemas, très simple pour les dessert par exemple ou plus descriptive pour les bowl.
const mongoose = require("mongoose");
const BowlSchema = new mongoose.Schema({
proteins: {
type: String,
required: true,
},
garnishes: {
type: [String],
required: true,
},
toppings: {
type: [String],
required: true,
},
});
module.exports = BowlSchema;
Il me restera plus qu'à tout vérifier une dernière fois avec postman.
10/09/2024 - 18h39
Un élément important que j'avais oublié ce matin c'est le orderSchema. Je ne suis pas sur qu'il soit optimisé mais ça fonctionne correctement.
const mongoose = require("mongoose");
const OrderSchema = new mongoose.Schema({
tableNumber: {
type: Number,
required: true,
},
items: {
type: [
{
type: {
type: String,
required: true,
enum: ["bowl", "side", "drink", "dessert", "custom"],
},
name: {
type: String,
required: true,
},
base: {
type: String, // Only for bowl and custom
default: "",
},
proteins: {
type: String,
// Only for custom
},
extraProtein: {
name: {
type: String, // Only for bowl and custom
},
quantity: {
type: Number,
},
},
garnishes: {
type: [String],
// Only for custom
},
toppings: {
type: [String],
// Only for custom
},
sauces: {
type: [String], // Only for bowl, custom and side
default: [],
},
quantity: {
type: Number,
required: true,
},
},
],
required: true,
},
specialInstructions: {
type: String,
},
});
const OrderModel = mongoose.model("Order", OrderSchema);
module.exports = OrderModel;
Tous les cas possible sont prévus dans items et je gérerai les conditions via le front.
10/09/2024 - 18h56
J'ai rajouté une dernière route pour les commandes. Toutes les éventualités me semble couverte.
const express = require("express");
const orderController = require("../controllers/order.controller");
const router = express.Router();
router.get("/", orderController.getAllOrders);
router.get("/:id", orderController.getOrder);
router.post("/", orderController.createOrder);
router.delete("/:id", orderController.deleteOrder);
router.get("/tables/:tableNumber", orderController.getTableOrders);
module.exports = router;
Je refais un tour avec postman pour tout tester, je commit le tout et je devrais pouvoir attaquer le front demain.
Je vais me garder ici un exemple de POST pour les commandes, c'est long à taper et je serais bien content de l'avoir plus tard.
{
"tableNumber": 666,
"items": [
{
"type": "bowl",
"name": "California",
"base": "riz",
"sauces": [
"soja sucré",
"soja salé"
],
"quantity": 2
},
{
"type": "custom",
"name": "Custom",
"base": "riz",
"proteins": "Saumon",
"extraProtein": {
"name": "Chicken",
"quantity": 1
},
"garnishes": [
"mangue",
"ananas"
],
"toppings": [
"oignons frits",
"cebette"
],
"sauces": [
"soja sucré",
"soja salé"
],
"quantity": 1
},
{
"type": "side",
"name": "Gyoza",
"sauces": [
"soja salé"
],
"quantity": 2
}
],
"specialInstructions": "J'aimerai manger chaud"
}
Le frontend
16/09/2024 - 10h31
Petit recap des avancées sur le front, puisque je n'ai rien écris depuis quelques jours.
On voulait une interface simple et fluide, pensée "mobile-first", pour que les clients puissent passer leurs commandes facilement depuis leur smartphone. Puis mise en place de Redux pour gérer l'état global de l'application.
export const getOrders = () => {
return (dispatch) => {
return axios.get(`${process.env.REACT_APP_API_URL}api/order`)
.then((res) => {
dispatch({ type: "GET_ORDERS", payload: res.data });
});
};
};
Ensuite, on a utilisé useSelector
et useDispatch
pour interagir avec le store Redux dans nos composants. Une base ultra simple, mais efficace pour gérer la synchronisation entre l'API et l'interface.
Pour la partie UI, j'ai choisi MUI pour créer une interface élégante et responsive. J'ai d'abord commencé par des composants de base comme des Cards pour afficher chaque commande, puis j'ai ajouté un système de Masonry pour bien organiser les cartes à l'écran.
<Card sx={{ minWidth: 275 }}>
<CardContent>
<Typography variant="h5">Commande</Typography>
<Typography color="textSecondary">Table : {order.tableNumber}</Typography>
<Typography variant="body2">Sauces : {order.sauces.join(", ")}</Typography>
</CardContent>
<CardActions>
<Button size="small">Imprimer</Button>
<Button size="small">Archiver</Button>
</CardActions>
</Card>
Chaque commande pouvait varier en fonction du type d'items (bowl, side, drink, etc.). Du coup, j'ai mis en place une gestion de ces différents types avec un bon vieux switch
pour rendre l'affichage dynamique en fonction des types de commandes.
switch (type) {
case "bowl":
return (
<>
<Typography variant="h6">{name} x{quantity}</Typography>
<Typography>Sauces : {sauces.join(", ")}</Typography>
</>
);
case "drink":
return <Typography>{name} - {size}</Typography>;
default:
return <Typography>Type de commande non reconnu</Typography>;
}
Côté sécurité, on a implémenté un système de JWT pour protéger certaines pages sensibles (comme le dashboard admin). J'ai utilisé useContext
pour stocker et vérifier le token d'authentification afin que seuls les utilisateurs connectés puissent accéder à certaines parties de l'application. Ça permet de vérifier si un utilisateur est connecté avant de le laisser accéder à une page sensible, comme la page des commandes ou des statistiques.
Retour dans le backend
18/09/2024 - 20h50
Après avoir bien avancé sur la partie front de mon projet, j'ai terminé l'intégration des trois onglets dans l'admin dashboard :
- Commandes en cours : Pour voir les commandes actives en temps réel.
- Commandes archivées : Pour consulter l'historique des commandes passées.
- Gestion des tables : Un toggle permet d'ouvrir et de fermer les tables, afin de bloquer les commandes extérieures (je prévois de trouver une meilleure solution à l'avenir, comme une restriction basée sur l'IP ou l'accès au réseau local).
Maintenant, je me concentre sur le backend, car c'est lui qui enverra les requêtes ESC/POS à l'imprimante via des sockets TCP/IP. Le but est d'envoyer directement les données des commandes à l'imprimante pour une impression automatique. Cependant, je rencontre un blocage : bien que les ports réseau du restaurant soient ouverts, je n'arrive toujours pas à joindre l'imprimante.
Imprimante thermique et Javascript
23/09/2024 - 16h16
Les ports c'est réglé. Une bête histoire de routeur qui faisait mal son taf. Et la société qui l'a installé ne voulait pas me donner le mot de passe pour accéder au dashboard
Pas si simple finalement. Heureusement que j'ai trouvé le dépôt Github ReceiptPrinterEncoder de Niels Leenheer. Il permet de traduire mon javascript en language ESC/POS. J'ai donc installé ce package directement dans mon back et je peux désormais imprimer mes commandes à distance via un call API.
Pour le fonctionnement, quand le serveur reçoit la commande, il crée une socket pour communiquer avec l'imprimante, ensuite les données sont encodés au bon format et envoyer à l'imprimante.
module.exports.printText = (orderData) => {
return new Promise((resolve, reject) => {
const client = createPrinterClient();
client.connect(process.env.PRINTER_PORT, process.env.PRINTER_HOST, () => {
console.log("[🧾 THERMAL] Connected to printer");
try {
const encoder = new EscPosEncoder();
let printData = encoder
.initialize()
.codepage("cp850")
.newline()
.text("Mon Restaurant\n")
.text(`Table: ${orderData.tableNumber}\n`)
.newline()
.text("------------------------------\n");
orderData.items.forEach((item) => {
printData.text(`${item.name} x${item.quantity}\n`);
if (item.base) {
printData.text(`Base: ${item.base}\n`);
}
if (item.proteins) {
printData.text(`Proteins: ${item.proteins}\n`);
}
if (item.garnishes && item.garnishes.length > 0) {
printData.text(`Garnishes: ${item.garnishes.join(", ")}\n`);
}
if (item.toppings && item.toppings.length > 0) {
printData.text(`Toppings: ${item.toppings.join(", ")}\n`);
}
if (item.sauces && item.sauces.length > 0) {
printData.text(`Sauces: ${item.sauces.join(", ")}\n`);
}
if (item.extraProtein) {
printData.text(
`Extra Protein: ${item.extraProtein.name} x${item.extraProtein.quantity}\n`
);
}
printData.newline();
});
printData
.text("------------------------------\n")
.text("Comments\n")
.text(`${orderData.specialInstructions}\n`)
.text("------------------------------\n")
.newline()
.newline()
.newline()
.newline()
.cut();
const encodedData = printData.encode();
client.write(Buffer.from(encodedData), () => {
console.log("[🧾 THERMAL] Sent data to printer");
client.end();
resolve("Printed successfully");
});
} catch (encodeError) {
console.error("[🧾 THERMAL] Encoding error:", encodeError);
client.end();
reject("Error encoding print data");
}
});
client.on("error", (err) => {
console.error("[🧾 THERMAL] Error connecting to printer:", err);
client.end();
reject("Error connecting to printer");
});
});
};
On passe côté user
Les tables
23/09/2024 - 16h16
Maintenant qu'on peut recevoir des commandes et les imprimer, il faudrait peut être pouvoir les envoyer. L'UI et l'UX n'étant pas mon fort, on va fort, je vais grandement m'inspiré de Deliveroo.
Pour être honnête, j'écris ces mots avec un peu de décalage. Voici donc les différents components que j'ai déjà designé en utilisant MUI.
Je vais remplir la base de donné avec l'ensemble de la carte, ça me permettra de voir s'il reste des choses à implémenter.
24/09/2024 - 19h05
La base de données est remplie. Ça m'a permis de me rendre compte qu'il fallait quelques modifications dans les schémas. J'ai aussi corrigé un peu l'UI de mes cards après avoir vu les différents cas de figure. Je note d'ailleurs qu'une augmentation du padding vertical est nécessaire pour le bouton + des cards populaires.
Demain, je vais commencer l'ajout des modals qui permettront de configurer les plats et l'ajout au panier. J'aimerais aussi trouver la solution pour fermer ces modals par un swipe vers le bas avec motion
de Framer.
Le drawer
25/09/2024 - 01h20
Je suis tombé sur une vidéo de Tom is Loading qui présente exactement ce dont j'avais besoin. Évidemment, je n’ai pas voulu attendre demain pour m’en occuper.
Donc voilà, j’ai créé un composant parfaitement réutilisable (il va être partout). On utilise motion
de Framer pour faire slider le drawer (et non pas un modal) du bas vers le haut, et on fait en sorte que seule la partie supérieure soit draggable pour le fermer.
Maintenant, il faudra que je regarde comment intégrer des éléments de Material UI pour maintenir une certaine homogénéité, mais je ne suis pas sûr que ce soit compatible.
import React from "react";
import {
motion,
useAnimate,
useDragControls,
useMotionValue,
} from "framer-motion";
const backgroundStyle = {
position: "fixed",
zIndex: 50,
backgroundColor: "rgba(0,0,0,0.5)",
width: "100vw",
height: "100vh",
top: 0,
left: 0,
};
const childrenStyle = {
position: "relative",
zIndex: 0,
height: "100%",
overflowY: "scroll",
padding: 10,
paddingTop: 40,
"&::-webkit-scrollbar": {
display: "none",
},
"-msOverflowStyle": "none",
scrollbarWidth: "none",
};
const btnContainerStyle = {
position: "absolute",
left: 0,
right: 0,
zIndex: 10,
display: "flex",
justifyContent: "center",
backgroundColor: "#fff",
padding: "8px",
height: "24px",
touchAction: "none",
};
const btnStyle = {
height: "4px",
width: "100px",
touchAction: "none",
borderRadius: 50,
backgroundColor: "rgba(0,0,0,0.2)",
cursor: "grab",
border: "none",
};
const BottomDrawer = ({ open, setOpen, children }) => {
const [scope, animate] = useAnimate();
const controls = useDragControls();
const y = useMotionValue(0);
const handleClose = async () => {
animate(scope.current, {
opacity: [1, 0],
});
const yStart = typeof y.get() === "number" ? y.get() : 0;
await animate("#drawer", {
y: [yStart, 500],
});
setOpen(false);
};
return (
<>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onClick={handleClose}
ref={scope}
style={backgroundStyle}
>
<motion.div
id="drawer"
onClick={(e) => e.stopPropagation()}
initial={{ y: "100%" }}
animate={{ y: "0%" }}
transition={{
ease: "easeInOut",
}}
onDragEnd={() => {
if (y.get() >= 100) {
handleClose();
}
}}
drag="y"
dragControls={controls}
dragListener={false}
dragConstraints={{
top: 0,
bottom: 0,
}}
dragElastic={{
top: 0,
bottom: 0.5,
}}
style={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
height: "95vh",
overflow: "hidden",
backgroundColor: "#fff",
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
y,
}}
>
<div
onPointerDown={(e) => {
controls.start(e);
}}
style={btnContainerStyle}
>
<button style={btnStyle} />
</div>
<div style={childrenStyle}>{children}</div>
</motion.div>
</motion.div>
)}
</>
);
};
export default BottomDrawer;