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 @@ @@ -104,16 +104,16 @@
{% for p in items %} - {% endfor %}