From 26c94c9ec6e6504d32d2099d02cfd764d37f7b6e Mon Sep 17 00:00:00 2001
From: Julien Constant <49886317+Juknum@users.noreply.github.com>
Date: Fri, 16 Dec 2022 00:37:07 +0100
Subject: [PATCH] [Eboutic] Fix double quote issue & improved user experience
on small screen (#522)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Fix #511 Regex issue with escaped double quotes
* Fix basket being when reloading the page (when cookie != "")
+ Added JSDoc
+ Cleaned some code
* Fix #509 Improved user experience on small screens
* Fix css class not being added back when reloading page
* CSS Fixes (see description)
+ Fixed overlaping item title with the cart emoji on small screen
+ Fixed minimal size of the basket on small screen (full width)
* Added darkened background circle to items with no image
* Fix issue were the basket could be None
* Edited CSS to have bette img ratio & the 🛒 icon
Adapt, Improve, Overcome
* Moved basket down on small screen size
---
eboutic/forms.py | 7 +-
eboutic/static/eboutic/css/eboutic.css | 150 ++++++++++++++----
eboutic/static/eboutic/js/eboutic.js | 153 +++++++++++++------
eboutic/templates/eboutic/eboutic_main.jinja | 16 +-
4 files changed, 234 insertions(+), 92 deletions(-)
diff --git a/eboutic/forms.py b/eboutic/forms.py
index 20e99797..c5842700 100644
--- a/eboutic/forms.py
+++ b/eboutic/forms.py
@@ -99,8 +99,11 @@ class BasketForm:
- all the ids refer to products the user is allowed to buy
- all the quantities are positive integers
"""
- basket = unquote(self.cookies.get("basket_items", None))
- if basket is None or basket in ("[]", ""):
+ # replace escaped double quotes by single quotes, as the RegEx used to check the json
+ # does not support escaped double quotes
+ basket = unquote(self.cookies.get("basket_items", "")).replace('\\"', "'")
+
+ if basket in ("[]", ""):
self.error_messages.add(_("You have no basket."))
return
diff --git a/eboutic/static/eboutic/css/eboutic.css b/eboutic/static/eboutic/css/eboutic.css
index aecb67a3..bc9757e2 100644
--- a/eboutic/static/eboutic/css/eboutic.css
+++ b/eboutic/static/eboutic/css/eboutic.css
@@ -28,7 +28,7 @@
@media screen and (max-width: 765px) {
#eboutic {
- flex-direction: column;
+ flex-direction: column-reverse;
align-items: center;
margin: 10px;
row-gap: 20px;
@@ -38,6 +38,7 @@
margin-top: 4px;
}
#basket {
+ width: -webkit-fill-available;
}
}
@@ -52,23 +53,39 @@
margin-bottom: 10px
}
+#eboutic .item-row {
+ gap: 10px;
+}
+
#eboutic .item-name {
- flex: 1;
+ word-break: break-word;
+ width: 100%;
+ line-height: 100%;
}
-#eboutic .item-price, #eboutic .item-quantity {
- width: 65px;
-}
-
-#eboutic .item-quantity {
+#eboutic .fa-plus,
+#eboutic .fa-minus {
+ cursor: pointer;
+ background-color: #354a5f;
+ color: white;
+ border-radius: 50%;
+ padding: 5px;
+ font-size: 10px;
+ line-height: 10px;
+ width: 10px;
text-align: center;
}
-#eboutic .fa-plus, #eboutic .fa-minus {
- cursor: pointer;
+#eboutic .item-quantity {
+ min-width: 65px;
+ justify-content: space-between;
+ align-items: center;
+ display: flex;
+ gap: 5px;
}
#eboutic .item-price {
+ min-width: 65px;
text-align: right;
}
@@ -91,44 +108,65 @@
column-gap: 15px;
row-gap: 15px;
}
-
-@media screen and (max-width: 765px) {
- #eboutic #catalog {
- row-gap: 15px;
- }
-
- #eboutic section {
- text-align: center;
- }
-
- #eboutic .product-group {
- justify-content: space-around;
- }
-}
-
#eboutic .product-button {
position: relative;
box-sizing: border-box;
- min-width: 120px;
- max-width: 150px;
- padding: 10px;
+ min-height: 180px;
+ height: fit-content;
+ width: 150px;
+ padding: 15px;
overflow: hidden;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
display: flex;
flex-direction: column;
align-items: center;
row-gap: 5px;
+ justify-content: flex-start;
+}
+
+#eboutic .product-button.selected {
+ animation: bg-in-out 1s ease;
+ background-color: rgb(216, 236, 255);
+}
+
+#eboutic .product-button.selected::after {
+ content: "🛒";
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ padding: 5px;
+ border-radius: 50%;
+ box-shadow: 0px 0px 12px 2px rgb(0 0 0 / 14%);
+ background-color: white;
+ width: 20px;
+ height: 20px;
+ font-size: 16px;
+ line-height: 20px;
}
#eboutic .product-button:active {
box-shadow: none;
}
-#eboutic .product-button img, #eboutic .product-button .fa {
- margin: 0;
+#eboutic .product-image {
+ width: 100%;
+ height: 100%;
+ min-height: 70px;
+ max-height: 70px;
+ object-fit: contain;
border-radius: 4px;
- height: 40px;
- line-height: 40px;
+ line-height: 70px;
+ margin-bottom: 15px;
+}
+
+#eboutic i.product-image {
+ background-color: rgba(173, 173, 173, 0.2);
+}
+
+#eboutic .product-description h4 {
+ font-size: .75em;
+ word-break: break-word;
+ margin: 0 0 5px 0;
}
#eboutic .product-button p {
@@ -152,8 +190,9 @@
font-weight: normal;
color: white;
min-width: 60px;
- padding: 5px 10px;
+ padding: 10px 15px;
}
+
#eboutic .catalog-buttons .validate {
background-color: #354a5f;
}
@@ -174,4 +213,51 @@
#eboutic .catalog-buttons form {
margin: 0;
+}
+
+@media screen and (max-width: 765px) {
+ #eboutic #catalog {
+ row-gap: 15px;
+ width: 100%;
+ }
+
+ #eboutic section {
+ text-align: center;
+ }
+
+ #eboutic .product-group {
+ justify-content: space-around;
+ flex-direction: column;
+ }
+
+ #eboutic .product-group .product-button {
+ min-height: 100px;
+ width: 100%;
+ max-width: 100%;
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ }
+
+ #eboutic .product-group .product-description {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ width: 100%;
+ }
+
+ #eboutic .product-description h4 {
+ text-align: left;
+ max-width: 90%;
+ }
+
+ #eboutic .product-image {
+ margin-bottom: 0px;
+ max-width: 70px;
+ }
+}
+
+@keyframes bg-in-out {
+ 0% { background-color: white; }
+ 100% { background-color: rgb(216, 236, 255); }
}
\ No newline at end of file
diff --git a/eboutic/static/eboutic/js/eboutic.js b/eboutic/static/eboutic/js/eboutic.js
index 8662d79f..a1a96db2 100644
--- a/eboutic/static/eboutic/js/eboutic.js
+++ b/eboutic/static/eboutic/js/eboutic.js
@@ -1,76 +1,123 @@
+/**
+ * @typedef {Object} BasketItem An item in the basket
+ * @property {number} id The id of the product
+ * @property {string} name The name of the product
+ * @property {number} quantity The quantity of the product
+ * @property {number} unit_price The unit price of the product
+ */
+
+const BASKET_ITEMS_COOKIE_NAME = "basket_items";
+
+/**
+ * Search for a cookie by name
+ * @param {string} name Name of the cookie to get
+ * @returns {string|null|undefined} the value of the cookie or null if it does not exist, undefined if not found
+ */
function getCookie(name) {
- let cookieValue = null;
- if (document.cookie && document.cookie !== '') {
- const cookies = document.cookie.split(';');
- for (let i = 0; i < cookies.length; i++) {
- const cookie = cookies[i].trim();
- // Does this cookie string begin with the name we want?
- if (cookie.substring(0, name.length + 1) === (name + '=')) {
- cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
- break;
- }
- }
- }
- return cookieValue;
+ if (!document.cookie || document.cookie.length === 0) return null;
+
+ let found = document.cookie
+ .split(';')
+ .map(c => c.trim())
+ .find(c => c.startsWith(name + '='));
+
+ return found === undefined ? undefined : decodeURIComponent(found.split('=')[1]);
}
+/**
+ * Fetch the basket items from the associated cookie
+ * @returns {BasketItem[]|[]} the items in the basket
+ */
function get_starting_items() {
- const cookie = getCookie("basket_items")
+ const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
+ let output = [];
+
try {
- // django cookie backend does an utter mess on non-trivial data types
+ // Django cookie backend does an utter mess on non-trivial data types
// so we must perform a conversion of our own
- const biscuit = JSON.parse(JSON.parse(cookie.replace(/\\054/g, ',')));
- if (Array.isArray(biscuit)) {
- return biscuit;
- }
- return [];
- } catch (e) {
- return [];
- }
+ const biscuit = JSON.parse(cookie.replace(/\\054/g, ','));
+ output = Array.isArray(biscuit) ? biscuit : [];
+
+ } catch (e) {}
+
+ output.forEach(item => {
+ let el = document.getElementById(item.id);
+ el.classList.add("selected");
+ });
+
+ return output;
}
document.addEventListener('alpine:init', () => {
Alpine.data('basket', () => ({
items: get_starting_items(),
+ /**
+ * Get the total price of the basket
+ * @returns {number} The total price of the basket
+ */
get_total() {
- let total = 0;
- for (const item of this.items) {
- total += item["quantity"] * item["unit_price"];
- }
- return total;
+ return this.items
+ .reduce((acc, item) => acc + item["quantity"] * item["unit_price"], 0);
},
+ /**
+ * Add 1 to the quantity of an item in the basket
+ * @param {BasketItem} item
+ */
add(item) {
item.quantity++;
- this.edit_cookies()
+ this.set_cookies();
},
+ /**
+ * Remove 1 to the quantity of an item in the basket
+ * @param {BasketItem} item_id
+ */
remove(item_id) {
const index = this.items.findIndex(e => e.id === item_id);
+
if (index < 0) return;
this.items[index].quantity -= 1;
+
if (this.items[index].quantity === 0) {
+ let el = document.getElementById(this.items[index].id);
+ el.classList.remove("selected");
+
this.items = this.items.filter((e) => e.id !== this.items[index].id);
}
- this.edit_cookies();
+ this.set_cookies();
},
+ /**
+ * Remove all the items from the basket & cleans the catalog CSS classes
+ */
clear_basket() {
- this.items = []
- this.edit_cookies();
+ // We remove the class "selected" from all the items in the catalog
+ this.items.forEach(item => {
+ let el = document.getElementById(item.id);
+ el.classList.remove("selected");
+ })
+
+ this.items = [];
+ this.set_cookies();
},
- edit_cookies() {
- // a cookie survives an hour
- document.cookie = "basket_items=" + encodeURIComponent(JSON.stringify(this.items)) + ";Max-Age=3600";
+ /**
+ * Set the cookie in the browser with the basket items
+ * ! the cookie survives an hour
+ */
+ set_cookies() {
+ if (this.items.length === 0) document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
+ else document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
},
/**
* Create an item in the basket if it was not already in
- * @param id : int the id of the product to add
- * @param name : String the name of the product
- * @param price : number the unit price of the product
+ * @param {number} id The id of the product to add
+ * @param {string} name The name of the product
+ * @param {number} price The unit price of the product
+ * @returns {BasketItem} The created item
*/
create_item(id, name, price) {
let new_item = {
@@ -79,25 +126,31 @@ document.addEventListener('alpine:init', () => {
quantity: 0,
unit_price: price
};
+
this.items.push(new_item);
this.add(new_item);
+
+ return new_item;
},
/**
- * add an item to the basket.
- * This is called when the user click
- * on a button in the catalog (left side of the page)
- * @param id : int the id of the product to add
- * @param name : String the name of the product
- * @param price : number the unit price of the product
+ * Add an item to the basket.
+ * This is called when the user click on a button in the catalog
+ * @param {number} id The id of the product to add
+ * @param {string} name The name of the product
+ * @param {number} price The unit price of the product
*/
add_from_catalog(id, name, price) {
- const item = this.items.find(e => e.id === id)
- if (item === undefined) {
- this.create_item(id, name, price);
- } else {
- // the user clicked on an item which is already in the basket
- this.add(item);
+ let item = this.items.find(e => e.id === id)
+
+ // if the item is not in the basket, we create it
+ // else we add + 1 to it
+ if (item === undefined) item = this.create_item(id, name, price);
+ else this.add(item);
+
+ if (item.quantity > 0) {
+ let el = document.getElementById(item.id);
+ el.classList.add("selected");
}
},
}))
diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja
index e707505b..b2ff6681 100644
--- a/eboutic/templates/eboutic/eboutic_main.jinja
+++ b/eboutic/templates/eboutic/eboutic_main.jinja
@@ -46,12 +46,12 @@