2026-04-13

This commit is contained in:
gcch 2026-04-11 10:53:06 +02:00
commit 08ad871e0c
9 changed files with 320 additions and 293 deletions

View file

@ -8,7 +8,6 @@ import type { MessageMajContenuPanierSchema } from "./lib/schemas/messages.ts";
import type { WCStoreCartItem } from "./lib/types/api/cart";
import type { MessageMajBoutonPanierDonnees, MessageMajContenuPanierDonnees } from "./lib/types/messages";
import { Effect } from "effect";
import { Effect } from "effect";
import {
ATTRIBUT_CLE_PANIER,

View file

@ -1,10 +1,23 @@
import { Array as FxArray, Console, Context, Effect, HashMap, Layer, ManagedRuntime, Option, pipe } from "effect";
// oxlint-disable typescript/dot-notation
import {
Array as FxArray,
Console,
Context,
Effect,
HashMap,
Layer,
ManagedRuntime,
Option,
pipe,
Stream,
} from "effect";
import type { NonEmptyReadonlyArray } from "effect/Array";
import type { NoSuchElementError } from "effect/Cause";
import { getAllSelectorFromDocument, getFirstSelectorFromDocument } from "../scripts-effect/lib/dom.ts";
import {
ATTRIBUT_ARIA_CONTROLS,
ATTRIBUT_ARIA_EXPANDED,
ATTRIBUT_DESACTIVE,
ATTRIBUT_HIDDEN,
DOM_BOUTON_AJOUT_PANIER,
DOM_BOUTONS_ACCORDEON,
@ -22,7 +35,7 @@ type DetailEnsemble = {
class ProductPageElements extends Context.Service<
ProductPageElements,
{
AddProductButton: HTMLButtonElement;
AddToCartButton: HTMLButtonElement;
Details: HashMap.HashMap<string, DetailEnsemble>;
DetailsButtons: NonEmptyReadonlyArray<HTMLButtonElement>;
DetailsContents: NonEmptyReadonlyArray<HTMLDivElement>;
@ -35,7 +48,7 @@ class ProductPageElements extends Context.Service<
static readonly layer = Layer.effect(
ProductPageElements,
Effect.gen(function*() {
const AddProductButton = yield* getFirstSelectorFromDocument<HTMLButtonElement>(DOM_BOUTON_AJOUT_PANIER);
const AddToCartButton = yield* getFirstSelectorFromDocument<HTMLButtonElement>(DOM_BOUTON_AJOUT_PANIER);
const DetailsButtons = yield* getAllSelectorFromDocument<HTMLButtonElement>(DOM_BOUTONS_ACCORDEON);
const DetailsContents = yield* getAllSelectorFromDocument<HTMLDivElement>(DOM_CONTENUS_ACCORDEON);
const ProductPrice = yield* getFirstSelectorFromDocument<HTMLParagraphElement>(DOM_PRIX_PRODUIT);
@ -62,7 +75,7 @@ class ProductPageElements extends Context.Service<
);
return ProductPageElements.of({
AddProductButton,
AddToCartButton,
Details,
DetailsButtons,
DetailsContents,
@ -78,10 +91,32 @@ class ProductPageElements extends Context.Service<
class ProductPageDOM extends Context.Service<
ProductPageDOM,
{
initPriceUpdatesOnVariationChange: () => Effect.Effect<void>;
onVariationChangeHandler: () => Effect.Effect<void>;
/**
* Récupère les Attributs du Produit depuis les Elements au sein du DOM.
*/
getProductAttributesFromDOM: () => Effect.Effect<ReadonlyArray<WCStoreCartAddItemArgsItems>>;
/**
* Initialise l'état initial du Bouton d'ajout au Panier.
*/
initAddToCartButtonInitialState: () => Effect.Effect<void>;
/**
* Initialise les mises à jour du Bouton d'ajout au Panier en fonction des interactions de l'Utilisateur.
*/
initAddToCartButtonUpdates: () => Effect.Effect<void>;
/**
* Initialise les interactions des Sections de la Description du Produit.
*/
initDetailInteractions: () => Effect.Effect<void, NoSuchElementError>;
/**
* Met à jour l'état des Sections de la Description du Produit.
*/
onDetailButtonClickHandler: (evt: Event) => Effect.Effect<void, NoSuchElementError>;
/**
* Met à jour l'état du Bouton d'ajout au Panier.
*/
onFormChangeHandler: (evt: Event) => Effect.Effect<void>;
/**
* Replie toutes les sections de la description du Produit.
*/
@ -91,7 +126,19 @@ class ProductPageDOM extends Context.Service<
static readonly layer = Layer.effect(
ProductPageDOM,
Effect.gen(function*() {
const { Details, VariationSelectors } = yield* ProductPageElements;
const { AddToCartButton, Details, ProductPrice, DetailsButtons, ProductRawJson, VariationChoiceForm, VariationSelectors } =
yield* ProductPageElements;
const onFormChangeHandler = Effect.fnUntraced(function*(evt: Event) {
// La cible ne peut qu'être un Formulaire.
const target: HTMLFormElement = evt.target as HTMLFormElement;
const isClickAllowed = target.checkValidity() === false;
// Active/désactive le Bouton en fonction de la validité du Formulaire du Produit.
AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, isClickAllowed);
return yield* Effect.void;
});
const toggleAllDetails: () => Effect.Effect<void> = () =>
Effect.sync((): void => {
@ -105,6 +152,37 @@ class ProductPageDOM extends Context.Service<
);
});
const onDetailButtonClickHandler = Effect.fnUntraced(function*(evt: Event) {
// Empêche la pollution de l'historique de navigation
evt.preventDefault();
// La cible est connue.
const target = evt.target as HTMLButtonElement;
// Récupère le contenu correspondant au Bouton.
const linkedSection = yield* pipe(
Option.fromNullishOr(target.getAttribute(ATTRIBUT_ARIA_CONTROLS)),
Option.flatMap((contentId: string) => HashMap.get(Details, contentId)),
);
// Sauvegarde l'état d'ouverture de la Section avant de toutes les fermer.
const wasCurrentSection: boolean = target.getAttribute(ATTRIBUT_ARIA_EXPANDED) === "true";
// Replie toutes les Sections.
yield* toggleAllDetails();
// Ne fais rien de plus si l'onglet sélectionné était le courant
if (wasCurrentSection === true) {
return yield* Effect.void;
}
// Ouvre le nouvel onglet sélectionné
target.toggleAttribute(ATTRIBUT_ARIA_EXPANDED, true);
linkedSection.content.toggleAttribute(ATTRIBUT_HIDDEN, false);
return yield* Effect.void;
});
const getProductAttributesFromDOM: () => Effect.Effect<ReadonlyArray<WCStoreCartAddItemArgsItems>> = () =>
Effect.sync(() =>
FxArray.map(VariationSelectors, (select: HTMLSelectElement) => ({
@ -113,8 +191,79 @@ class ProductPageDOM extends Context.Service<
}))
);
const initAddToCartButtonInitialState = Effect.fn("initAddToCartButtonInitialState")(function*() {
/** Est-ce que le Produit affiché est en stock ? */
const isProductInStock = AddToCartButton.hasAttribute("data-in-stock") === true;
// S'il n'y a pas de stock, ne rien faire.
if (isProductInStock === false) {
return yield* Effect.void;
}
// S'il n'y a pas de Sélecteurs de variations, activer le Bouton d'ajout au Panier.
if (FxArray.isReadonlyArrayEmpty(VariationSelectors)) {
AddToCartButton.removeAttribute(ATTRIBUT_DESACTIVE);
}
return yield* Effect.void;
});
const initAddToCartButtonUpdates = Effect.fn("initAddToCartInteractionUpdates")(function*() {
return yield* pipe(
Stream.fromEventListener(VariationChoiceForm, "change"),
Stream.tap(onFormChangeHandler),
Stream.runDrain,
);
});
const initPriceUpdatesOnVariationChange = Effect.fn("initPriceUpdatesOnVariationChange")(function*(){
return yield* pipe(
Stream.fromEventListener(VariationChoiceForm, "change"),
Stream.tap(onVariationChangeHandler),
Stream.runDrain,
)
});
const onVariationChangeHandler = Effect.fn("onVariationChangeHandler")(function*(){
if (VariationChoiceForm.checkValidity() === false) {
return yield* Effect.void;
}
const variations = JSON.parse(ProductRawJson.textContent)?.variations as ReadonlyArray<unknown>;
const chosenAttributes = yield* getProductAttributesFromDOM();
const equivalence = FxArray.makeEquivalence<{attribute: string,value: string}>((a,b) => {
return a.attribute === b.attribute && a.value === b.value;
});
const chosenVariation = yield* FxArray.findFirst(variations, variation => equivalence(variation.attributes, chosenAttributes));
const newPrice = chosenVariation.price;
ProductPrice.textContent = `${newPrice}`;
return yield* Effect.void;
});
const initDetailInteractions = Effect.fn("initDetailInteractions")(function*() {
return yield* pipe(
// Créé un Stream par Bouton de Section.
FxArray.map(
DetailsButtons,
(button: HTMLButtonElement) =>
pipe(Stream.fromEventListener(button, "click"), Stream.tap(onDetailButtonClickHandler)),
),
Stream.mergeAll({ concurrency: "unbounded" }),
Stream.runDrain,
);
});
return ProductPageDOM.of({
getProductAttributesFromDOM,
initAddToCartButtonInitialState,
initAddToCartButtonUpdates,
initDetailInteractions,
initPriceUpdatesOnVariationChange,
onDetailButtonClickHandler,
onFormChangeHandler,
onVariationChangeHandler,
toggleAllDetails,
});
}),

View file

@ -1,40 +1,10 @@
// Scripts pour la Page Produit
import { pipe } from "@mobily/ts-belt";
import { get as dictGet } from "@mobily/ts-belt/Dict";
import { tap as optionTap } from "@mobily/ts-belt/Option";
import { Array as FxArray, Console, Effect, HashMap, Option, pipe as epipe, Stream } from "effect";
import { EitherAsync } from "purify-ts";
import { match, P } from "ts-pattern";
import { ValiError } from "valibot";
import type { AnySchema } from "valibot";
import { Console, Effect, pipe as epipe } from "effect";
import type { WCStoreCartAddItemArgs, WCStoreCartAddItemArgsItems } from "./lib/types/api/cart-add-item.ts";
import type { WCStoreCart } from "./lib/types/api/cart.ts";
import type { FetchErrors } from "./lib/types/reseau.ts";
import { ROUTE_API_AJOUTE_ARTICLE_PANIER } from "./constantes/api.ts";
import {
ATTRIBUT_ARIA_CONTROLS,
ATTRIBUT_ARIA_EXPANDED,
ATTRIBUT_CHARGEMENT,
ATTRIBUT_DESACTIVE,
ATTRIBUT_HIDDEN,
DOM_BOUTON_AJOUT_PANIER,
DOM_BOUTONS_ACCORDEON,
DOM_CONTENUS_ACCORDEON,
DOM_DOM_QUANTITE,
DOM_PRIX_PRODUIT,
} from "./constantes/dom.ts";
import { lanceAnimationCycleLoading } from "./lib/animations.ts";
import { mustGetEleInDocument, mustGetElesInDocument, recupereElementDocumentEither } from "./lib/dom.ts";
import { BadRequestError, reporteErreur, ServerError } from "./lib/erreurs.ts";
import { emetMessageMajBoutonPanier } from "./lib/messages.ts";
import { newPartialResponse, postBackend, safeFetch } from "./lib/reseau.ts";
import { WCStoreCartAddItemArgsSchema } from "./lib/schemas/api/cart-add-item.ts";
import { WCStoreCartSchema } from "./lib/schemas/api/cart.ts";
import { safeSchemaParse } from "./lib/validation.ts";
import { ProductPageElements, ProductPageRuntime } from "./scripts-page-produit-service.ts";
import { ProductPageRuntime } from "./scripts-page-produit-service.ts";
/** États utiles pour les scripts de la page. */
type EtatsPage = {
@ -72,199 +42,93 @@ const updatePriceOnAttributeChange = (): void => {
});
};
const ajouteProduitAuPanier = (event: MouseEvent): void => {
event.preventDefault();
console.debug("getAttributeValuesFromDom", getAttributesFromDom());
// const ajouteProduitAuPanier = (event: MouseEvent): void => {
// event.preventDefault();
// console.debug("getAttributeValuesFromDom", getAttributesFromDom());
// Construis les arguments de la requête au backend
const argsRequete: WCStoreCartAddItemArgs = {
id: E.DOM_VARIATION.map((selecteur: HTMLSelectElement): number => Number(selecteur.value))
// Récupère l'ID du Produit de la Page pour les Produits simples
.orDefault(ETATS_PAGE.idProduit),
quantity: 1,
variation: getAttributesFromDom(),
};
// // Construis les arguments de la requête au backend
// const argsRequete: WCStoreCartAddItemArgs = {
// id: E.DOM_VARIATION.map((selecteur: HTMLSelectElement): number => Number(selecteur.value))
// // Récupère l'ID du Produit de la Page pour les Produits simples
// .orDefault(ETATS_PAGE.idProduit),
// quantity: 1,
// variation: getAttributesFromDom(),
// };
// Réalise la Requête et traite sa Réponse
void EitherAsync
// 1. Valide les arguments de la Requête
.liftEither(safeSchemaParse(argsRequete, WCStoreCartAddItemArgsSchema))
// 2. Exécute un Effet pour empêcher les requêtes concurrentes et lancer une animation de chargement
.ifRight(() => {
// Désactive le Bouton pour empêcher des requêtes concurrentes
E.BOUTON_AJOUT_PANIER.setAttribute(ATTRIBUT_DESACTIVE, "");
E.BOUTON_AJOUT_PANIER.setAttribute(ATTRIBUT_CHARGEMENT, "");
// // Réalise la Requête et traite sa Réponse
// void EitherAsync
// // 1. Valide les arguments de la Requête
// .liftEither(safeSchemaParse(argsRequete, WCStoreCartAddItemArgsSchema))
// // 2. Exécute un Effet pour empêcher les requêtes concurrentes et lancer une animation de chargement
// .ifRight(() => {
// // Désactive le Bouton pour empêcher des requêtes concurrentes
// E.BOUTON_AJOUT_PANIER.setAttribute(ATTRIBUT_DESACTIVE, "");
// E.BOUTON_AJOUT_PANIER.setAttribute(ATTRIBUT_CHARGEMENT, "");
// Lance un cycle d'animation sur le texte de chargement
lanceAnimationCycleLoading(E.BOUTON_AJOUT_PANIER, 500);
})
// 3. Exécute la requête via fetch sous forme d'EitherAsync
.chain((args: WCStoreCartAddItemArgs) =>
safeFetch(
postBackend({
corps: JSON.stringify(args),
nonce: ETATS_PAGE.nonce,
route: ROUTE_API_AJOUTE_ARTICLE_PANIER,
}),
)
)
// 4. Traite les cas d'Erreurs et récupère le Corps de la Réponse
.chain((reponse: Response) =>
EitherAsync<BadRequestError | ServerError, unknown>(async ({ throwE }) =>
// Simplifie les données à matcher
match(await newPartialResponse(reponse))
.with({ status: 500 }, () => throwE(new ServerError("500 Server Error")))
.with({ status: 400 }, () => throwE(new BadRequestError("400 Bad Request Error")))
.with({ status: 201 }, r => r.body)
.otherwise(erreur => throwE(new Error(`Erreur inconnue ${String(erreur.status)}`)))
)
)
// 5. Vérifie le Schéma de la Réponse
.chain((corpsReponse: unknown) => EitherAsync.liftEither(safeSchemaParse(corpsReponse, WCStoreCartSchema)))
// 6. Exécute un Effet pour la mise à jour du DOM avec les Résultats
.ifRight((panier: WCStoreCart) =>
pipe(
dictGet(panier, "items_count"),
optionTap((totalArticles: number) => {
E.BOUTON_AJOUT_PANIER.textContent = "Added to cart!";
emetMessageMajBoutonPanier({ quantiteProduits: totalArticles });
}),
)
)
.ifLeft((erreur: BadRequestError | FetchErrors | ServerError | ValiError<AnySchema>) => {
match(erreur)
.with(P.instanceOf(ValiError), e => {
reporteErreur(e);
console.error(e.issues);
// E.MESSAGE_ADRESSES.textContent = ERREUR_GENERIQUE_SOUMISSION_ADRESSES;
})
.with(P.instanceOf(ServerError), P.instanceOf(BadRequestError), e => {
reporteErreur(e);
console.error(e);
// E.MESSAGE_ADRESSES.textContent = ERREUR_GENERIQUE_SOUMISSION_ADRESSES;
})
.with(P.instanceOf(DOMException), P.instanceOf(TypeError), P.instanceOf(Error), e => {
reporteErreur(e);
console.error(e);
// E.MESSAGE_ADRESSES.textContent = ERREUR_GENERIQUE_RESEAU;
})
.exhaustive();
// // Lance un cycle d'animation sur le texte de chargement
// lanceAnimationCycleLoading(E.BOUTON_AJOUT_PANIER, 500);
// })
// // 3. Exécute la requête via fetch sous forme d'EitherAsync
// .chain((args: WCStoreCartAddItemArgs) =>
// safeFetch(
// postBackend({
// corps: JSON.stringify(args),
// nonce: ETATS_PAGE.nonce,
// route: ROUTE_API_AJOUTE_ARTICLE_PANIER,
// }),
// )
// )
// // 4. Traite les cas d'Erreurs et récupère le Corps de la Réponse
// .chain((reponse: Response) =>
// EitherAsync<BadRequestError | ServerError, unknown>(async ({ throwE }) =>
// // Simplifie les données à matcher
// match(await newPartialResponse(reponse))
// .with({ status: 500 }, () => throwE(new ServerError("500 Server Error")))
// .with({ status: 400 }, () => throwE(new BadRequestError("400 Bad Request Error")))
// .with({ status: 201 }, r => r.body)
// .otherwise(erreur => throwE(new Error(`Erreur inconnue ${String(erreur.status)}`)))
// )
// )
// // 5. Vérifie le Schéma de la Réponse
// .chain((corpsReponse: unknown) => EitherAsync.liftEither(safeSchemaParse(corpsReponse, WCStoreCartSchema)))
// // 6. Exécute un Effet pour la mise à jour du DOM avec les Résultats
// .ifRight((panier: WCStoreCart) =>
// pipe(
// dictGet(panier, "items_count"),
// optionTap((totalArticles: number) => {
// E.BOUTON_AJOUT_PANIER.textContent = "Added to cart!";
// emetMessageMajBoutonPanier({ quantiteProduits: totalArticles });
// }),
// )
// )
// .ifLeft((erreur: BadRequestError | FetchErrors | ServerError | ValiError<AnySchema>) => {
// match(erreur)
// .with(P.instanceOf(ValiError), e => {
// reporteErreur(e);
// console.error(e.issues);
// // E.MESSAGE_ADRESSES.textContent = ERREUR_GENERIQUE_SOUMISSION_ADRESSES;
// })
// .with(P.instanceOf(ServerError), P.instanceOf(BadRequestError), e => {
// reporteErreur(e);
// console.error(e);
// // E.MESSAGE_ADRESSES.textContent = ERREUR_GENERIQUE_SOUMISSION_ADRESSES;
// })
// .with(P.instanceOf(DOMException), P.instanceOf(TypeError), P.instanceOf(Error), e => {
// reporteErreur(e);
// console.error(e);
// // E.MESSAGE_ADRESSES.textContent = ERREUR_GENERIQUE_RESEAU;
// })
// .exhaustive();
E.BOUTON_AJOUT_PANIER.textContent = "Add to cart";
})
.finally((): void => {
// Désactive l'animation de chargement et rend le Bouton de nouveau cliquable
E.BOUTON_AJOUT_PANIER.removeAttribute(ATTRIBUT_CHARGEMENT);
E.BOUTON_AJOUT_PANIER.removeAttribute(ATTRIBUT_DESACTIVE);
})
.run();
};
/**
* Initialise l'état initial d'interactivité du Bouton d'ajout de Produit au Panier.
*/
const initAddToCartButton = Effect.fn("initAddToCartButton")(function*() {
const { AddProductButton, VariationSelectors } = yield* ProductPageElements;
/** Est-ce que le Produit affiché est en stock ? */
const isProductInStock = AddProductButton.hasAttribute("data-in-stock") === true;
// S'il n'y a pas de stock, ne rien faire.
if (isProductInStock === false) {
console.debug("initAddToCartButton", "Pas de stock.");
return yield* Effect.void;
}
// S'il n'y a pas de Sélecteurs de variations, activer le Bouton d'ajout au Panier.
if (FxArray.isReadonlyArrayEmpty(VariationSelectors)) {
console.debug("initAddToCartButton", "Produt simple.");
E.BOUTON_AJOUT_PANIER.removeAttribute(ATTRIBUT_DESACTIVE);
}
return yield* Effect.void;
});
const onFormChange = Effect.fnUntraced(function*(evt: Event) {
const { AddProductButton } = yield* ProductPageElements;
// La cible ne peut qu'être un Formulaire.
const target: HTMLFormElement = evt.target as HTMLFormElement;
const isClickAllowed = target.checkValidity() === false;
// Active/désactive le Bouton en fonction de la validité du Formulaire du Produit.
AddProductButton.toggleAttribute(ATTRIBUT_DESACTIVE, isClickAllowed);
return yield* Effect.void;
});
/**
* Initialise la mise à jour de l'état d'interactivité du Bouton d'ajout de Produit au Panier en fonction des actions de l'Utilisateur.
*/
const initAddToCartInteractionUpdates = Effect.fn("initAddToCartInteractionUpdates")(function*() {
return yield* pipe(
Stream.fromEventListener(E.VARIATION_CHOICE_FORM, "change"),
Stream.tap(onFormChange),
Stream.runDrain,
);
});
const onDetailButtonClick = Effect.fnUntraced(function*(evt: Event) {
const { Details } = yield* ProductPageElements;
// Empêche la pollution de l'historique de navigation
evt.preventDefault();
// La cible est connue.
const target = evt.target as HTMLButtonElement;
// Récupère le contenu correspondant.
const linkedSection = yield* pipe(
Option.fromNullishOr(target.getAttribute(ATTRIBUT_ARIA_CONTROLS)),
Option.flatMap((contentId: string) => HashMap.get(Details, contentId)),
);
// Sauvegarde l'état d'ouverture de la Section avant de toutes les fermer.
const wasCurrentSection: boolean = target.getAttribute(ATTRIBUT_ARIA_EXPANDED) === "true";
// Replie toutes les Sections.
yield* toggleAllDetails();
// Ne fais rien de plus si l'onglet sélectionné était le courant
if (wasCurrentSection === true) {
return yield* Effect.void;
}
// Ouvre le nouvel onglet sélectionné
target.toggleAttribute(ATTRIBUT_ARIA_EXPANDED, true);
linkedSection.content.toggleAttribute(ATTRIBUT_HIDDEN, false);
return yield* Effect.void;
});
const initDetailInteractions = Effect.fn("initDetailInteractions")(function*() {
const PageElements = yield* ProductPageElements;
return yield* pipe(
FxArray.map(
PageElements.DetailsButtons,
(button: HTMLButtonElement) => pipe(Stream.fromEventListener(button, "click"), Stream.tap(onDetailButtonClick)),
),
Stream.mergeAll({ concurrency: "unbounded" }),
Stream.runDrain,
);
});
const getAttributesFromDom = (): ReadonlyArray<WCStoreCartAddItemArgsItems> => {
const selectElements = epipe(
document.querySelectorAll<HTMLSelectElement>(".selecteur-produit select"),
Array.from<HTMLSelectElement>,
);
if (selectElements.length === 0) {
return [];
}
const attributes = selectElements.map((select: HTMLSelectElement) => ({
attribute: select.id,
value: select.value,
}));
return attributes;
};
// E.BOUTON_AJOUT_PANIER.textContent = "Add to cart";
// })
// .finally((): void => {
// // Désactive l'animation de chargement et rend le Bouton de nouveau cliquable
// E.BOUTON_AJOUT_PANIER.removeAttribute(ATTRIBUT_CHARGEMENT);
// E.BOUTON_AJOUT_PANIER.removeAttribute(ATTRIBUT_DESACTIVE);
// })
// .run();
// };
document.addEventListener("DOMContentLoaded", (): void => {
ProductPageRuntime.runFork(pipe(initAddToCartButton(), Effect.tapCause(Console.error)));