Merge pull request #959 from ae-utbm/counter-click-step-4

Make counter click client side first
This commit is contained in:
Bartuccio Antoine
2024-12-27 22:06:35 +01:00
committed by GitHub
21 changed files with 1464 additions and 885 deletions

View File

@ -0,0 +1,25 @@
import type { Product } from "#counter:counter/types";
export class BasketItem {
quantity: number;
product: Product;
quantityForTrayPrice: number;
errors: string[];
constructor(product: Product, quantity: number) {
this.quantity = quantity;
this.product = product;
this.errors = [];
}
getBonusQuantity(): number {
if (!this.product.hasTrayPrice) {
return 0;
}
return Math.floor(this.quantity / this.product.quantityForTrayPrice);
}
sum(): number {
return (this.quantity - this.getBonusQuantity()) * this.product.price;
}
}

View File

@ -15,6 +15,10 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
return ["FIN", "ANN"];
}
public getSelectedProduct(): [number, string] {
return parseProduct(this.widget.getValue() as string);
}
protected attachBehaviors(): void {
this.allowMultipleProducts();
}

View File

@ -1,42 +1,100 @@
import { exportToHtml } from "#core:utils/globals";
import type TomSelect from "tom-select";
interface CounterConfig {
csrfToken: string;
clickApiUrl: string;
sessionBasket: Record<number, BasketItem>;
customerBalance: number;
customerId: number;
}
interface BasketItem {
// biome-ignore lint/style/useNamingConvention: talking with python
bonus_qty: number;
price: number;
qty: number;
}
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
exportToHtml("loadCounter", (config: CounterConfig) => {
document.addEventListener("alpine:init", () => {
Alpine.data("counter", () => ({
basket: config.sessionBasket,
basket: {} as Record<string, BasketItem>,
errors: [],
customerBalance: config.customerBalance,
codeField: undefined,
alertMessage: {
content: "",
show: false,
timeout: null,
},
init() {
// Fill the basket with the initial data
for (const entry of config.formInitial) {
if (entry.id !== undefined && entry.quantity !== undefined) {
this.addToBasket(entry.id, entry.quantity);
this.basket[entry.id].errors = entry.errors ?? [];
}
}
this.codeField = this.$refs.codeField;
this.codeField.widget.focus();
// It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here
this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS")
.setAttribute(":value", "getBasketSize()");
},
removeFromBasket(id: string) {
delete this.basket[id];
},
addToBasket(id: string, quantity: number): ErrorMessage {
const item: BasketItem =
this.basket[id] || new BasketItem(config.products[id], 0);
const oldQty = item.quantity;
item.quantity += quantity;
if (item.quantity <= 0) {
delete this.basket[id];
return "";
}
this.basket[id] = item;
if (this.sumBasket() > this.customerBalance) {
item.quantity = oldQty;
if (item.quantity === 0) {
delete this.basket[id];
}
return gettext("Not enough money");
}
return "";
},
getBasketSize() {
return Object.keys(this.basket).length;
},
sumBasket() {
if (!this.basket || Object.keys(this.basket).length === 0) {
if (this.getBasketSize() === 0) {
return 0;
}
const total = Object.values(this.basket).reduce(
(acc: number, cur: BasketItem) => acc + cur.qty * cur.price,
(acc: number, cur: BasketItem) => acc + cur.sum(),
0,
) as number;
return total / 100;
return total;
},
showAlertMessage(message: string) {
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.content = message;
this.alertMessage.show = true;
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.show = false;
this.alertMessage.timeout = null;
}, 2000);
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.showAlertMessage(message);
}
},
onRefillingSuccess(event: CustomEvent) {
@ -50,33 +108,36 @@ exportToHtml("loadCounter", (config: CounterConfig) => {
this.codeField.widget.focus();
},
async handleCode(event: SubmitEvent) {
const widget: TomSelect = this.codeField.widget;
const code = (widget.getValue() as string).toUpperCase();
if (this.codeField.getOperationCodes().includes(code)) {
$(event.target).submit();
} else {
await this.handleAction(event);
finish() {
if (this.getBasketSize() === 0) {
this.showAlertMessage(gettext("You can't send an empty basket."));
return;
}
widget.clear();
widget.focus();
this.$refs.basketForm.submit();
},
async handleAction(event: SubmitEvent) {
const payload = $(event.target).serialize();
const request = new Request(config.clickApiUrl, {
method: "POST",
body: payload,
headers: {
// biome-ignore lint/style/useNamingConvention: this goes into http headers
Accept: "application/json",
"X-CSRFToken": config.csrfToken,
},
});
const response = await fetch(request);
const json = await response.json();
this.basket = json.basket;
this.errors = json.errors;
cancel() {
location.href = config.cancelUrl;
},
handleCode() {
const [quantity, code] = this.codeField.getSelectedProduct() as [
number,
string,
];
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
if (code === "ANN") {
this.cancel();
}
if (code === "FIN") {
this.finish();
}
} else {
this.addToBasketWithMessage(code, quantity);
}
this.codeField.widget.clear();
this.codeField.widget.focus();
},
}));
});
@ -85,7 +146,7 @@ exportToHtml("loadCounter", (config: CounterConfig) => {
$(() => {
/* Accordion UI between basket and refills */
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
($("#click_form") as any).accordion({
($("#click-form") as any).accordion({
heightStyle: "content",
activate: () => $(".focus").focus(),
});

View File

@ -0,0 +1,25 @@
type ErrorMessage = string;
export interface InitialFormData {
/* Used to refill the form when the backend raises an error */
id?: keyof Record<string, Product>;
quantity?: number;
errors?: string[];
}
export interface CounterConfig {
customerBalance: number;
customerId: number;
products: Record<string, Product>;
formInitial: InitialFormData[];
cancelUrl: string;
}
export interface Product {
id: string;
code: string;
name: string;
price: number;
hasTrayPrice: boolean;
quantityForTrayPrice: number;
}