Implémentation 3DSv2 + résolution bugs eboutic + amélioration pages admin (#558)

Eboutic :
- Implémentation de la norme 3DSecure v2 pour les paiement par carte bancaire
- Amélioration générale de l'interface utilisateur
- Résolution du problème avec les caractères spéciaux dans le panier sur Safari
- Réparation du cookie du panier de l'eboutic qui n'était pas fonctionnel

Autre :
- Mise à jour de la documentation
- Mise à jour des dépendances Javascript
- Suppression du code inutilisé dans `subscription/models.py`
- Amélioration des pages administrateur (back-office Django)

Co-authored-by: thomas girod <56346771+imperosol@users.noreply.github.com>
Co-authored-by: Théo DURR <git@theodurr.fr>
Co-authored-by: Julien Constant <julienconstant190@gmail.com>
This commit is contained in:
thomas girod
2023-01-09 20:53:12 +01:00
committed by GitHub
parent 310f1a2283
commit 73305c0b28
53 changed files with 3461 additions and 2248 deletions

View File

@ -0,0 +1,263 @@
#eboutic {
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
column-gap: 20px;
margin: 0 20px 20px;
}
#eboutic-title {
margin-left: 20px;
}
#eboutic h3 {
margin-left: 0;
margin-right: 0;
}
#basket {
min-width: 300px;
border-radius: 8px;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
padding: 10px;
}
#basket h3 {
margin-top: 0;
}
@media screen and (max-width: 765px) {
#eboutic {
flex-direction: column-reverse;
align-items: center;
margin: 10px;
row-gap: 20px;
}
#eboutic-title {
margin-bottom: 20px;
margin-top: 4px;
}
#basket {
width: -webkit-fill-available;
}
}
#eboutic .item-list {
margin-left: 0;
list-style: none;
}
#eboutic .item-list li {
display: flex;
align-items: center;
margin-bottom: 10px
}
#eboutic .item-row {
gap: 10px;
}
#eboutic .item-name {
word-break: break-word;
width: 100%;
line-height: 100%;
}
#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 .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;
}
/* CSS du catalogue */
#eboutic #catalog {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 30px;
}
#eboutic .category-header {
margin-bottom: 15px;
}
#eboutic .product-group {
display: flex;
flex-wrap: wrap;
column-gap: 15px;
row-gap: 15px;
}
#eboutic .product-button {
position: relative;
box-sizing: border-box;
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-image {
width: 100%;
height: 100%;
min-height: 70px;
max-height: 70px;
object-fit: contain;
border-radius: 4px;
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 {
font-size: 13px;
margin: 0;
}
#eboutic .catalog-buttons {
display: flex;
justify-content: center;
column-gap: 30px;
margin: 30px 0 0;
}
#eboutic input {
all: unset;
}
#eboutic .catalog-buttons button {
font-size: 15px!important;
font-weight: normal;
color: white;
min-width: 60px;
padding: 10px 15px;
}
#eboutic .catalog-buttons .validate {
background-color: #354a5f;
}
#eboutic .catalog-buttons .clear {
background-color: gray;
}
#eboutic .catalog-buttons button i {
margin-right: 4px;
}
#eboutic .catalog-buttons button.validate:hover {
background-color: #2c3646;
}
#eboutic .catalog-buttons button.clear:hover {
background-color:hsl(210,5%,30%);
}
#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); }
}

View File

@ -0,0 +1,157 @@
/**
* @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) {
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_COOKIE_NAME);
let output = [];
try {
// 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(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() {
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.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.set_cookies();
},
/**
* Remove all the items from the basket & cleans the catalog CSS classes
*/
clear_basket() {
// 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();
},
/**
* 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 {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 = {
id: id,
name: name,
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
* @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) {
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");
}
},
}))
})

View File

@ -0,0 +1,73 @@
document.addEventListener('alpine:init', () => {
Alpine.store('bank_payment_enabled', false)
Alpine.store('billing_inputs', {
data: JSON.parse(et_data)["data"],
async fill() {
document.getElementById("bank-submit-button").disabled = true;
const request = new Request(et_data_url, {
method: "GET",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
});
const res = await fetch(request);
if (res.ok) {
const json = await res.json();
if (json["data"]) {
this.data = json["data"];
}
document.getElementById("bank-submit-button").disabled = false;
}
}
})
Alpine.data('billing_infos', () => ({
errors: [],
successful: false,
url: billing_info_exist ? edit_billing_info_url : create_billing_info_url,
async send_form() {
const form = document.getElementById("billing_info_form");
const submit_button = form.querySelector("input[type=submit]")
submit_button.disabled = true;
document.getElementById("bank-submit-button").disabled = true;
this.successful = false
let payload = {};
for (const elem of form.querySelectorAll("input")) {
if (elem.type === "text" && elem.value) {
payload[elem.name] = elem.value;
}
}
const country = form.querySelector("select");
if (country && country.value) {
payload[country.name] = country.value;
}
const request = new Request(this.url, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken(),
},
body: JSON.stringify(payload),
});
const res = await fetch(request);
const json = await res.json();
if (json["errors"]) {
this.errors = json["errors"];
} else {
this.errors = [];
this.successful = true;
this.url = edit_billing_info_url;
Alpine.store("billing_inputs").fill();
}
submit_button.disabled = false;
}
}))
})