[Eboutic] Fix double quote issue & improved user experience on small screen (#522)

* 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
This commit is contained in:
Julien Constant 2022-12-16 00:37:07 +01:00 committed by GitHub
parent 639197f4c8
commit 26c94c9ec6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 234 additions and 92 deletions

View File

@ -99,8 +99,11 @@ class BasketForm:
- all the ids refer to products the user is allowed to buy - all the ids refer to products the user is allowed to buy
- all the quantities are positive integers - all the quantities are positive integers
""" """
basket = unquote(self.cookies.get("basket_items", None)) # replace escaped double quotes by single quotes, as the RegEx used to check the json
if basket is None or basket in ("[]", ""): # 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.")) self.error_messages.add(_("You have no basket."))
return return

View File

@ -28,7 +28,7 @@
@media screen and (max-width: 765px) { @media screen and (max-width: 765px) {
#eboutic { #eboutic {
flex-direction: column; flex-direction: column-reverse;
align-items: center; align-items: center;
margin: 10px; margin: 10px;
row-gap: 20px; row-gap: 20px;
@ -38,6 +38,7 @@
margin-top: 4px; margin-top: 4px;
} }
#basket { #basket {
width: -webkit-fill-available;
} }
} }
@ -52,23 +53,39 @@
margin-bottom: 10px margin-bottom: 10px
} }
#eboutic .item-row {
gap: 10px;
}
#eboutic .item-name { #eboutic .item-name {
flex: 1; word-break: break-word;
width: 100%;
line-height: 100%;
} }
#eboutic .item-price, #eboutic .item-quantity { #eboutic .fa-plus,
width: 65px; #eboutic .fa-minus {
} cursor: pointer;
background-color: #354a5f;
#eboutic .item-quantity { color: white;
border-radius: 50%;
padding: 5px;
font-size: 10px;
line-height: 10px;
width: 10px;
text-align: center; text-align: center;
} }
#eboutic .fa-plus, #eboutic .fa-minus { #eboutic .item-quantity {
cursor: pointer; min-width: 65px;
justify-content: space-between;
align-items: center;
display: flex;
gap: 5px;
} }
#eboutic .item-price { #eboutic .item-price {
min-width: 65px;
text-align: right; text-align: right;
} }
@ -91,44 +108,65 @@
column-gap: 15px; column-gap: 15px;
row-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 { #eboutic .product-button {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
min-width: 120px; min-height: 180px;
max-width: 150px; height: fit-content;
padding: 10px; width: 150px;
padding: 15px;
overflow: hidden; overflow: hidden;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px; box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
row-gap: 5px; 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 { #eboutic .product-button:active {
box-shadow: none; box-shadow: none;
} }
#eboutic .product-button img, #eboutic .product-button .fa { #eboutic .product-image {
margin: 0; width: 100%;
height: 100%;
min-height: 70px;
max-height: 70px;
object-fit: contain;
border-radius: 4px; border-radius: 4px;
height: 40px; line-height: 70px;
line-height: 40px; 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 { #eboutic .product-button p {
@ -152,8 +190,9 @@
font-weight: normal; font-weight: normal;
color: white; color: white;
min-width: 60px; min-width: 60px;
padding: 5px 10px; padding: 10px 15px;
} }
#eboutic .catalog-buttons .validate { #eboutic .catalog-buttons .validate {
background-color: #354a5f; background-color: #354a5f;
} }
@ -174,4 +213,51 @@
#eboutic .catalog-buttons form { #eboutic .catalog-buttons form {
margin: 0; 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); }
} }

View File

@ -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) { function getCookie(name) {
let cookieValue = null; if (!document.cookie || document.cookie.length === 0) return null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';'); let found = document.cookie
for (let i = 0; i < cookies.length; i++) { .split(';')
const cookie = cookies[i].trim(); .map(c => c.trim())
// Does this cookie string begin with the name we want? .find(c => c.startsWith(name + '='));
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); return found === undefined ? undefined : decodeURIComponent(found.split('=')[1]);
break;
}
}
}
return cookieValue;
} }
/**
* Fetch the basket items from the associated cookie
* @returns {BasketItem[]|[]} the items in the basket
*/
function get_starting_items() { function get_starting_items() {
const cookie = getCookie("basket_items") const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
let output = [];
try { 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 // so we must perform a conversion of our own
const biscuit = JSON.parse(JSON.parse(cookie.replace(/\\054/g, ','))); const biscuit = JSON.parse(cookie.replace(/\\054/g, ','));
if (Array.isArray(biscuit)) { output = Array.isArray(biscuit) ? biscuit : [];
return biscuit;
} } catch (e) {}
return [];
} catch (e) { output.forEach(item => {
return []; let el = document.getElementById(item.id);
} el.classList.add("selected");
});
return output;
} }
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
Alpine.data('basket', () => ({ Alpine.data('basket', () => ({
items: get_starting_items(), items: get_starting_items(),
/**
* Get the total price of the basket
* @returns {number} The total price of the basket
*/
get_total() { get_total() {
let total = 0; return this.items
for (const item of this.items) { .reduce((acc, item) => acc + item["quantity"] * item["unit_price"], 0);
total += item["quantity"] * item["unit_price"];
}
return total;
}, },
/**
* Add 1 to the quantity of an item in the basket
* @param {BasketItem} item
*/
add(item) { add(item) {
item.quantity++; 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) { remove(item_id) {
const index = this.items.findIndex(e => e.id === item_id); const index = this.items.findIndex(e => e.id === item_id);
if (index < 0) return; if (index < 0) return;
this.items[index].quantity -= 1; this.items[index].quantity -= 1;
if (this.items[index].quantity === 0) { 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.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() { clear_basket() {
this.items = [] // We remove the class "selected" from all the items in the catalog
this.edit_cookies(); 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 * Set the cookie in the browser with the basket items
document.cookie = "basket_items=" + encodeURIComponent(JSON.stringify(this.items)) + ";Max-Age=3600"; * ! 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 * Create an item in the basket if it was not already in
* @param id : int the id of the product to add * @param {number} id The id of the product to add
* @param name : String the name of the product * @param {string} name The name of the product
* @param price : number the unit price of the product * @param {number} price The unit price of the product
* @returns {BasketItem} The created item
*/ */
create_item(id, name, price) { create_item(id, name, price) {
let new_item = { let new_item = {
@ -79,25 +126,31 @@ document.addEventListener('alpine:init', () => {
quantity: 0, quantity: 0,
unit_price: price unit_price: price
}; };
this.items.push(new_item); this.items.push(new_item);
this.add(new_item); this.add(new_item);
return new_item;
}, },
/** /**
* add an item to the basket. * Add an item to the basket.
* This is called when the user click * This is called when the user click on a button in the catalog
* on a button in the catalog (left side of the page) * @param {number} id The id of the product to add
* @param id : int the id of the product to add * @param {string} name The name of the product
* @param name : String the name of the product * @param {number} price The unit price of the product
* @param price : number the unit price of the product
*/ */
add_from_catalog(id, name, price) { add_from_catalog(id, name, price) {
const item = this.items.find(e => e.id === id) let item = this.items.find(e => e.id === id)
if (item === undefined) {
this.create_item(id, name, price); // if the item is not in the basket, we create it
} else { // else we add + 1 to it
// the user clicked on an item which is already in the basket if (item === undefined) item = this.create_item(id, name, price);
this.add(item); else this.add(item);
if (item.quantity > 0) {
let el = document.getElementById(item.id);
el.classList.add("selected");
} }
}, },
})) }))

View File

@ -46,12 +46,12 @@
</li> </li>
<template x-for="item in items" :key="item.id"> <template x-for="item in items" :key="item.id">
<li class="item-row" x-show="item.quantity > 0"> <li class="item-row" x-show="item.quantity > 0">
<span class="item-name" x-text="item.name"></span>
<div class="item-quantity"> <div class="item-quantity">
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i> <i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
<span x-text="item.quantity"></span> <span x-text="item.quantity"></span>
<i class="fa fa-plus" @click="add(item)"></i> <i class="fa fa-plus" @click="add(item)"></i>
</div> </div>
<span class="item-name" x-text="item.name"></span>
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span> <span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
</li> </li>
</template> </template>
@ -104,16 +104,16 @@
</div> </div>
<div class="product-group"> <div class="product-group">
{% for p in items %} {% for p in items %}
<button class="product-button" <button id="{{ p.id }}" class="product-button" @click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'>
@click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'>
{% if p.icon %} {% if p.icon %}
<img src="{{ p.icon.url }}" alt="image de {{ p.name }}" <img class="product-image" src="{{ p.icon.url }}" alt="image de {{ p.name }}">
width="40px" height="40px">
{% else %} {% else %}
<i class="fa fa-2x fa-picture-o"></i> <i class="fa fa-2x fa-picture-o product-image" ></i>
{% endif %} {% endif %}
<p><strong>{{ p.name }}</strong></p> <div class="product-description">
<p>{{ p.selling_price }} €</p> <h4>{{ p.name }}</strong></h4>
<p>{{ p.selling_price }} €</p>
</div>
</button> </button>
{% endfor %} {% endfor %}
</div> </div>