ref: refactorise les scripts de la page Produit

This commit is contained in:
gcch 2026-04-14 11:30:27 +02:00
commit 4fe1056eab
11 changed files with 494 additions and 118 deletions

View file

@ -0,0 +1,52 @@
import { Context, Effect, Layer, Schema } from "effect";
import { ENTETE_WC_NONCE, ROUTE_API_AJOUTE_ARTICLE_PANIER } from "../scripts/constantes/api.ts";
import { CartProduct } from "./schemas/api.ts";
/** Délai par défaut pour la réalisation d'une Requête. */
const REQUEST_TIMEOUT = 5_000;
/** Représente un soucis lors de l'exécution d'une Requête auprès de l'API WooCommerce. */
class APIError extends Schema.TaggedErrorClass<APIError>()("APIError", {
cause: Schema.Defect,
}) {}
class WooCommerceAPI extends Context.Service<
WooCommerceAPI,
{
AddProductToCart: (nonce: string, requestBody: CartProduct) => Effect.Effect<Response, APIError>;
}
>()("haikuatelier.fr/WooCommerceAPI") {
static readonly layer = Layer.effect(
WooCommerceAPI,
// oxlint-disable-next-line require-yield
Effect.gen(function*() {
const AddProductToCart = Effect.fn("AddProductToCart")(function*(nonce: string, product: CartProduct) {
const response = yield* Effect.tryPromise({
catch: error => new APIError({ cause: error }),
try: async () =>
fetch(ROUTE_API_AJOUTE_ARTICLE_PANIER, {
// Convertis en chaîne de caractères (équivalent à JSON.stringify).
body: Schema.encodeSync(Schema.fromJsonString(CartProduct))(product),
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
[ENTETE_WC_NONCE]: nonce,
},
method: "POST",
mode: "same-origin",
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
}),
});
return response;
});
return WooCommerceAPI.of({
AddProductToCart,
});
}),
);
}
export { APIError, WooCommerceAPI };

View file

@ -0,0 +1,91 @@
import { Console, Context, Effect, Layer, pipe, Schedule, Schema, SchemaIssue } from "effect";
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http";
import type { CartProduct } from "../schemas/api.ts";
class BodyParsingError extends Schema.TaggedErrorClass<BodyParsingError>()("BodyParsingError", {
cause: Schema.String,
}) {}
class FetchError extends Schema.TaggedErrorClass<FetchError>()("FetchError", {
cause: Schema.Defect,
}) {}
class ApiRequestError extends Schema.TaggedErrorClass<ApiRequestError>()("ApiRequestError", {
reason: Schema.Union([BodyParsingError, FetchError]),
}) {}
/** Client `fetch` contenant les options et en-têtes de Requêtes pré-renseignées. */
const ApiFetchClient = FetchHttpClient.layer.pipe(
Layer.provide(
Layer.succeed(
FetchHttpClient.RequestInit,
{
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
mode: "same-origin",
},
),
),
);
class ApiClient extends Context.Service<ApiClient, {
AddProductToCart: (nonce: string, product: CartProduct) => Effect.Effect<unknown, ApiRequestError>;
}>()("haikuatelier.fr/ApiClient") {
static readonly layer = Layer.effect(
ApiClient,
Effect.gen(function*() {
// Créé un client HTTP où chaque Requête est imprimée dans la console.
const httpClient: HttpClient.HttpClient.With<HttpClientError.HttpClientError> = pipe(
yield* HttpClient.HttpClient,
HttpClient.tapRequest(Console.debug),
// Définis une politique d'essai.
HttpClient.retryTransient({
retryOn: "errors-only",
schedule: Schedule.exponential("1 seconds"),
times: 3,
}),
);
const AddProductToCart = Effect.fn("AppClient.AddProductToCart")(
function*(nonce: string, productToAdd: CartProduct): Effect.fn.Return<unknown, ApiRequestError> {
const request = pipe(
HttpClientRequest.post(`/wp-json/wc/store/cart/add-item`),
HttpClientRequest.setHeader("Nonce", nonce),
// Le corps de la Requête a été validée en amont.
HttpClientRequest.bodyJsonUnsafe(productToAdd),
);
const response = yield* pipe(
httpClient.execute(request),
// TODO: Remplacer Schema.Unknown par un Schéma de l'objet retourné par le backend.
Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Unknown)),
Effect.mapError(error => {
if (error._tag === "SchemaError") {
return new ApiRequestError({
reason: new BodyParsingError({ cause: SchemaIssue.makeFormatterDefault()(error.issue) }),
});
} else {
return new ApiRequestError({ reason: new FetchError({ cause: error.reason }) });
}
}),
Effect.tap(Console.debug),
);
return response;
},
);
return ApiClient.of({ AddProductToCart });
}),
).pipe(Layer.provide(ApiFetchClient));
}
export { ApiClient, ApiRequestError };

View file

@ -0,0 +1,88 @@
import { Context, Effect, flow, Layer, pipe, Schedule, Schema } from "effect";
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
import { HttpClientError } from "effect/unstable/http/HttpClientError";
class Todo extends Schema.Class<Todo>("Todo")({
userId: Schema.Number,
id: Schema.Number,
title: Schema.String,
completed: Schema.Boolean,
}) {}
class FetchClientError extends Schema.TaggedErrorClass<FetchClientError>()("FetchClientError", {
cause: Schema.Defect,
}) {}
class FetchClientExample extends Context.Service<FetchClientExample, {
readonly allTodos: Effect.Effect<ReadonlyArray<Todo>, FetchClientError>;
createTodo(todo: Omit<Todo, "id">): Effect.Effect<Todo, FetchClientError>;
getTodo(id: number): Effect.Effect<Todo, FetchClientError>;
}>()("FetchClientExample") {
static readonly layer = Layer.effect(
FetchClientExample,
Effect.gen(function*() {
// Access the HttpClient service, and apply some common middleware to all requests.
const client: HttpClient.HttpClient.With<HttpClientError> = pipe(
yield* HttpClient.HttpClient,
// Add a base URL to all requests with this client, and set the Accept header to expect JSON Response.
HttpClient.mapRequest(flow(
HttpClientRequest.prependUrl("https://jsonplaceholder.typicode.com"),
HttpClientRequest.acceptJson,
)),
// Fail if the Response status is not 2xx.
HttpClient.filterStatusOk,
// Retry transient errors with an exponential backoff.
HttpClient.retryTransient({ schedule: Schedule.exponential(100), times: 3 }),
);
const allTodos = client.get("/todos").pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Array(Todo))),
Effect.mapError(cause => new FetchClientError({ cause })),
Effect.withSpan("FetchClientExample.allTodos"),
);
// Use the HttpClient to fetch a Todo item by ID, and decode response using the Schema.
const getTodo = Effect.fn("FetchClientExample.getTodo")(function*(id: number) {
// Annotate the current span with the ID of the Todo being fetched so that it shows up in telemetry.
yield* Effect.annotateCurrentSpan({ id });
const todo = yield* pipe(
client.get(`/todos/${id}`, {
// You can pass additional options to individual Requests.
urlParams: { format: "json" },
}),
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)),
Effect.mapError(cause => new FetchClientError({ cause })),
);
return todo;
});
// You can use the HttpClientRequest module to build up complex Requests.
const createTodo = Effect.fn("createTodo")(function*(todo: Omit<Todo, "id">) {
yield* Effect.annotateCurrentSpan({ title: todo.title });
const createdTodo: Todo = yield* HttpClientRequest.post("/todos").pipe(
HttpClientRequest.setUrlParams({ format: "json" }),
HttpClientRequest.bodyJsonUnsafe(todo),
client.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)),
Effect.mapError(cause => new FetchClientError({ cause })),
);
return createdTodo;
});
return FetchClientExample.of({
allTodos,
getTodo,
createTodo,
});
}),
).pipe(
// Provides the fetch-based HttpClient implementation.
Layer.provide(FetchHttpClient.layer),
);
}
export { FetchClientError, FetchClientExample };

View file

@ -1,10 +1,10 @@
import { Schema } from "effect";
import { ProductAttribute } from "./product.ts";
import { ProductId, ProductQuantity, ProductVariationAttribute } from "./product.ts";
class AddProductToCart extends Schema.Class<AddProductToCart>("AddProductToCart")({
id: Schema.Int,
quantity: Schema.Int.check(Schema.isGreaterThan(0)),
variation: Schema.Array(ProductAttribute),
class CartProduct extends Schema.Class<CartProduct>("CartProduct")({
id: ProductId,
quantity: ProductQuantity,
variation: Schema.Array(ProductVariationAttribute),
}) {}
export { AddProductToCart };
export { CartProduct };

View file

@ -1,7 +1,49 @@
// oxlint-disable no-magic-numbers -- Pas besoin ici.
import { Schema } from "effect";
import { Effect, Option, pipe, Schema, SchemaIssue, SchemaTransformation } from "effect";
import type { SchemaError } from "effect/Schema";
/** Représente l'identifiant numérique unique d'un Produit. */
const ProductId = Schema.Int.pipe(Schema.brand("ProductId")).check(Schema.isGreaterThan(0));
/** Représente l'identifiant numérique unique d'un Attribut. */
const AttributeId = Schema.Int.pipe(Schema.brand("AttributeId")).check(Schema.isGreaterThan(0));
/** Réprésente une quantité (nombre d'unités) de Produit. */
const ProductQuantity = Schema.Int.pipe(Schema.brand("ProductQuantity")).check(Schema.isGreaterThanOrEqualTo(0));
/** Schéma transformant une chaîne de caractères en `ProductId` avec validation. */
const ProductQuantityFromString = Schema.String.pipe(
Schema.decodeTo(
ProductQuantity,
SchemaTransformation.transformOrFail({
decode: (value: string) =>
pipe(
globalThis.Number(value),
(number: number) => ProductQuantity.makeEffect(number),
Effect.mapError((error: SchemaError) => new SchemaIssue.InvalidValue(Option.some(value), { cause: error })),
),
encode: (value: number) => Effect.succeed(String(value)),
}),
),
);
class AttributeValue extends Schema.Class<AttributeValue>("AttributeValue")({
/** L'identifiant numérique de la Valeur de l'Attribut. */
id: AttributeId,
/** Le nom (texte affiché) de la Valeur de l'Attribut. */
name: Schema.String,
/** L'identifiant alphanumérique _(slug)_ de la Valeur de l'Attribut. */
slug: Schema.String,
}) {}
class ProductAttribute extends Schema.Class<ProductAttribute>("ProductAttribute")({
/** Le nom de l'Attribut. */
name: Schema.String,
options: Schema.Array(AttributeValue),
/** L'identifiant _(slug)_ de l'Attribut. */
slug: Schema.String,
}) {}
class ProductVariationAttribute extends Schema.Class<ProductVariationAttribute>("ProductVariationAttribute")({
/** L'identifiant _(slug)_ de l'Attribut. */
attribute: Schema.String,
/** La valeur de l'attribut. */
@ -10,11 +52,32 @@ class ProductAttribute extends Schema.Class<ProductAttribute>("ProductAttribute"
class ProductVariation extends Schema.Class<ProductVariation>("ProductVariation")({
/** Les Attributs présents pour cette Variation. */
attributes: Schema.Array(ProductAttribute),
attributes: Schema.Array(ProductVariationAttribute),
/** L'identifiant numérique unique de la Variation. */
id: Schema.Int.check(Schema.isGreaterThan(0)),
id: ProductId,
/** Le prix de la Variation. */
price: Schema.NonEmptyString,
price: ProductQuantityFromString,
}) {}
export { ProductAttribute, ProductVariation };
class Product extends Schema.Class<Product>("Product")({
/** Les Attributs applicables au Produit (en cas de Produit simple). */
attributes: Schema.Union([Schema.Record(Schema.String, ProductAttribute), Schema.Array(ProductAttribute)]),
/** L'identifiant numérique unique du Produit. */
id: ProductId,
/** Le prix du Produit. */
price: ProductQuantityFromString,
/** Les Variations existantes du Produit (en cas de Produit variable). */
variations: Schema.Array(ProductVariation),
}) {}
export {
AttributeId,
AttributeValue,
Product,
ProductAttribute,
ProductId,
ProductQuantity,
ProductQuantityFromString,
ProductVariation,
ProductVariationAttribute,
};

View file

@ -0,0 +1,18 @@
import { Schema, SchemaIssue } from "effect";
import { NoSuchElementError } from "effect/Cause";
import { SchemaError } from "effect/Schema";
class IncoherentDOMError extends Schema.TaggedErrorClass<IncoherentDOMError>()("IncoherentDOMError", {
cause: Schema.String,
}) {
static readonly fromSchemaError = (error: SchemaError): IncoherentDOMError =>
new IncoherentDOMError({
cause: SchemaIssue.makeFormatterDefault()(error.issue),
});
static readonly fromNoSuchElementError = (error: NoSuchElementError): IncoherentDOMError =>
new IncoherentDOMError({
cause: `Impossible de trouver l'Element suivant dans le DOM : ${error.message}.`,
});
}
export { IncoherentDOMError };

View file

@ -1,11 +1,15 @@
import { Console, Layer, ManagedRuntime, pipe } from "effect";
import { WooCommerceAPI } from "../../scripts-effect/api-service.ts";
import { ApiClient } from "../../scripts-effect/lib/api.ts";
import ProductPageDOM from "./service-dom.ts";
import ProductPageElements from "./service-elements.ts";
const ProductPageRuntime = ManagedRuntime.make(
pipe(
ProductPageDOM.layer,
Layer.provide(ApiClient.layer),
Layer.provide(ProductPageElements.layer),
Layer.provide(WooCommerceAPI.layer),
Layer.tapError(error => Console.error("ProductPageRuntime", "Impossible de créer le Layer :", error)),
),
);

View file

@ -14,17 +14,36 @@ import {
Stream,
} from "effect";
import type { NoSuchElementError } from "effect/Cause";
import { AddProductToCart } from "../../scripts-effect/schemas/api.ts";
import { ProductAttribute, ProductVariation } from "../../scripts-effect/schemas/product.ts";
import type { SchemaError } from "effect/Schema";
import { ApiClient, ApiRequestError } from "../../scripts-effect/lib/api.ts";
import { CartProduct } from "../../scripts-effect/schemas/api.ts";
import { Product, ProductVariation, ProductVariationAttribute } from "../../scripts-effect/schemas/product.ts";
import {
ATTRIBUT_ARIA_CONTROLS,
ATTRIBUT_ARIA_EXPANDED,
ATTRIBUT_CHARGEMENT,
ATTRIBUT_DESACTIVE,
ATTRIBUT_HIDDEN,
} from "../constantes/dom.ts";
import { lanceAnimationCycleLoading } from "../lib/animations.ts";
import { IncoherentDOMError } from "./errors.ts";
import ProductPageElements from "./service-elements.ts";
import type { DetailEnsemble } from "./types.d.ts";
const PageStatesSchema = Schema.Struct({
nonce: Schema.NonEmptyString,
product: Product,
});
class InvalidPageStateError extends Schema.TaggedErrorClass<InvalidPageStateError>()("InvalidPageStateError", {
cause: Schema.String,
}) {
static readonly fromSchemaError = (schemaError: SchemaError): InvalidPageStateError =>
new InvalidPageStateError({
cause: SchemaIssue.makeFormatterDefault()(schemaError.issue),
});
}
class ProductPageDOM extends Context.Service<
ProductPageDOM,
{
@ -48,7 +67,7 @@ class ProductPageDOM extends Context.Service<
* Replie toutes les sections de la description du Produit.
*/
initAddToCartButtonClicks: () => unknown;
ProductVariations: ReadonlyArray<ProductVariation>;
PageStates: typeof PageStatesSchema.Type;
CurrentVariation: Ref.Ref<Option.Option<ProductVariation>>;
}
>()("haikuatelier.fr/Produit/ProductPageDOM") {
@ -60,11 +79,23 @@ class ProductPageDOM extends Context.Service<
Details,
DetailsButtons,
ProductPrice,
ProductRawJson,
VariationChoiceForm,
VariationSelectors,
PageStatesRawJson,
} = yield* ProductPageElements;
const onFormChangeHandler = Effect.fn("onFormChangeHandler")(function*(evt: Event): Effect.fn.Return<void> {
const API = yield* ApiClient;
const PageStates = yield* pipe(
PageStatesRawJson.textContent,
(textContent: string) =>
Schema.decodeUnknownEffect(Schema.fromJsonString(PageStatesSchema))(textContent, { errors: "all" }),
Effect.mapError(InvalidPageStateError.fromSchemaError),
Effect.tapError(Console.error),
);
const CurrentProduct = yield* Ref.make(Option.none<ProductVariation>());
const onFormChangeHandler = Effect.fn("onFormChangeHandler")(function*(evt: Event) {
// La cible ne peut qu'être un Formulaire.
const target: HTMLFormElement = evt.target as HTMLFormElement;
const isClickAllowed = target.checkValidity() === false;
@ -75,21 +106,21 @@ class ProductPageDOM extends Context.Service<
return yield* Effect.void;
});
const toggleAllDetails: () => Effect.Effect<void> = () =>
const toggleAllDetails: (shouldOpen: boolean) => Effect.Effect<void> = (shouldOpen: boolean) =>
Effect.sync((): void => {
console.debug("toggleAllDetails");
pipe(
// Récupère les Sections sous forme d'Ensembles.
[...HashMap.values(Details)],
FxArray.forEach((detail: DetailEnsemble) => {
detail.button.toggleAttribute(ATTRIBUT_ARIA_EXPANDED, false);
detail.content.toggleAttribute(ATTRIBUT_HIDDEN, true);
detail.button.toggleAttribute(ATTRIBUT_ARIA_EXPANDED, shouldOpen);
detail.content.toggleAttribute(ATTRIBUT_HIDDEN, !shouldOpen);
}),
);
});
const onDetailButtonClickHandler = Effect.fn("onDetailButtonClickHandler")(function*(
evt: Event,
): Effect.fn.Return<void, NoSuchElementError> {
const onDetailButtonClickHandler = Effect.fn("onDetailButtonClickHandler")(
function*(evt: Event) {
// Empêche la pollution de l'historique de navigation
evt.preventDefault();
@ -100,31 +131,32 @@ class ProductPageDOM extends Context.Service<
const linkedSection = yield* pipe(
Option.fromNullishOr(target.getAttribute(ATTRIBUT_ARIA_CONTROLS)),
Option.flatMap((contentId: string) => HashMap.get(Details, contentId)),
Effect.fromOption,
Effect.mapError(
() => new IncoherentDOMError({ cause: "Un bouton de section ne correspond à aucun contenu." }),
),
);
// 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();
yield* toggleAllDetails(false);
// Ne fais rien de plus si l'onglet sélectionné était le courant
// 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é
// Ouvre le nouvel onglet sélectionné.
target.toggleAttribute(ATTRIBUT_ARIA_EXPANDED, true);
linkedSection.content.toggleAttribute(ATTRIBUT_HIDDEN, false);
return yield* Effect.void;
});
const ProductVariations: ReadonlyArray<ProductVariation> = yield* pipe(
JSON.parse(ProductRawJson.textContent)?.variations,
json => Schema.decodeUnknownEffect(Schema.Array(ProductVariation))(json, { onExcessProperty: "ignore" }),
Effect.mapError(error => SchemaIssue.makeFormatterStandardSchemaV1()(error.issue)),
Effect.tapCause(Console.error),
},
Effect.tapError(Console.error),
// Ouvre toutes les Sections en cas d'erreur.
Effect.catch(() => toggleAllDetails(true)),
);
const getChosenProductAttributesFromDOM = Effect.fn("getChosenProductAttributesFromDOM")(function*() {
@ -133,60 +165,99 @@ class ProductPageDOM extends Context.Service<
attribute: select.id,
value: select.value,
})),
variations => Schema.decodeEffect(Schema.Array(ProductAttribute))(variations),
Effect.mapError(error => SchemaIssue.makeFormatterDefault()(error.issue)),
Effect.tapCause(Console.error),
variations =>
Schema.decodeEffect(Schema.Array(ProductVariationAttribute))(variations, {
errors: "all",
onExcessProperty: "error",
}),
Effect.mapError(IncoherentDOMError.fromSchemaError),
Effect.tapError(Console.error),
);
});
const CurrentVariation = yield* Ref.make(Option.none<ProductVariation>());
const onVariationChangeHandler = Effect.fn("onVariationChangeHandler")(function*(): Effect.fn.Return<
void,
NoSuchElementError | string
> {
yield* Console.debug("onVariationChangeHandler");
const onVariationChangeHandler = Effect.fn("onVariationChangeHandler")(function*() {
yield* Console.log("onVariationChangeHandler");
// Ne fais rien si le Formulaire n'est pas valide.
if (VariationChoiceForm.checkValidity() === false) {
yield* Console.debug("onVariationChangeHandler", "Le formulaire est invalide.");
return yield* Effect.void;
}
const equivalence = Schema.toEquivalence(Schema.Array(ProductAttribute));
const equivalence = Schema.toEquivalence(Schema.Array(ProductVariationAttribute));
const chosenProductAttributes = yield* getChosenProductAttributesFromDOM();
const chosenVariation: ProductVariation = yield* FxArray.findFirst(
ProductVariations,
(variation: ProductVariation) => equivalence(variation.attributes, chosenProductAttributes),
yield* Console.debug("onVariationChangeHandler", "chosenProductAttributes", chosenProductAttributes);
const chosenVariation = yield* pipe(
FxArray.findFirst(
PageStates.product.variations,
variation => equivalence(variation.attributes, chosenProductAttributes),
),
Effect.fromOption,
Effect.mapError(error => new Error("Impossible de trouver la variation demandée.", { cause: error })),
);
yield* Console.debug("onVariationChangeHandler", "chosenVariation", chosenVariation);
// Met à jour la valeur de la Variation choisie dans le Service.
yield* Ref.set(Option.some(chosenVariation))(CurrentVariation);
yield* Ref.set(Option.some(chosenVariation))(CurrentProduct);
const newPrice = chosenVariation.price;
const newPrice = String(chosenVariation.price);
ProductPrice.textContent = `${newPrice}`;
return yield* Effect.void;
}, Effect.tapCause(Console.error));
}, Effect.tapError(Console.error));
const onAddToCartButtonHandler = Effect.fn("onAddToCartButtonHandler")(function*() {
const chosenVariation = yield* Ref.getUnsafe(CurrentVariation);
const productDetails = yield* Schema.decodeEffect(AddProductToCart)(
{
id: chosenVariation.id,
quantity: 1,
variation: chosenVariation.attributes,
},
{ errors: "all" },
const recoverFromBackendFailure = Effect.fn("recoverFromBackendFailure")(function*(error: ApiRequestError) {
yield* Console.error(error.reason);
AddToCartButton.textContent = "Error while adding the Product to the Cart...";
return yield* Effect.void;
});
/**
* Déclenche une ajout du Produit demandé au Panier auprès du Backend.
*/
const addToCartButtonClickHandler = Effect.fn("addToCartButtonClickHandler")(
function*() {
yield* Console.log("addToCartButtonClickHandler");
// Créé le corps de la requête
const requestBody: CartProduct = yield* pipe(
Ref.get(CurrentProduct),
Effect.flatMap(Effect.fromOption),
// Pour un Produit simple, le Ref sera vide.
Effect.orElseSucceed(() =>
ProductVariation.make({
attributes: [],
id: PageStates.product.id,
price: PageStates.product.price,
})
),
Effect.map(({ id, attributes }) =>
// Les données ont été validées en amont.
Schema.decodeSync(CartProduct)({ id: id, quantity: 1, variation: attributes })
),
Effect.tap(body => Console.debug("addToCartButtonClickHandler", "requestBody", body)),
);
console.debug(productDetails);
});
// Désactive les interactions le temps de la requête.
AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, true);
AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, true);
lanceAnimationCycleLoading(AddToCartButton, 500);
// Exécute la Requête auprès du backend.
yield* API.AddProductToCart(PageStates.nonce, requestBody);
AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, false);
AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, false);
AddToCartButton.textContent = "Add to cart";
},
Effect.tapError(Console.error),
Effect.catchTag("ApiRequestError", recoverFromBackendFailure),
);
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.
// S'('y a pas de stock, ne rien faire.
if (isProductInStock === false) {
return yield* Effect.void;
}
@ -199,31 +270,31 @@ class ProductPageDOM extends Context.Service<
return yield* Effect.void;
});
const initAddToCartButtonUpdates = Effect.fn("initAddToCartInteractionUpdates")(function*() {
const initAddToCartButtonUpdates = Effect.fn("initAddToCartInteractionUpdates")(
function*(): Effect.fn.Return<void> {
return yield* pipe(
Stream.fromEventListener(VariationChoiceForm, "change"),
Stream.tap(onFormChangeHandler),
Stream.runDrain,
);
});
},
);
const initAddToCartButtonClicks = Effect.fn("initAddToCartButtonClicks")(function*() {
return yield* pipe(
Stream.fromEventListener(AddToCartButton, "click"),
Stream.tap(onAddToCartButtonHandler),
Stream.tap(addToCartButtonClickHandler),
Stream.runDrain,
);
});
const initPriceUpdatesOnVariationChange = Effect.fn("initPriceUpdatesOnVariationChange")(
function*(): Effect.fn.Return<void, NoSuchElementError | string> {
const initPriceUpdatesOnVariationChange = Effect.fn("initPriceUpdatesOnVariationChange")(function*() {
return yield* pipe(
Stream.fromEventListener(VariationChoiceForm, "change"),
Stream.tap(onVariationChangeHandler),
Stream.runDrain,
);
},
);
});
const initDetailInteractions = Effect.fn("initDetailInteractions")(function*() {
return yield* pipe(
@ -239,13 +310,13 @@ class ProductPageDOM extends Context.Service<
});
return ProductPageDOM.of({
CurrentVariation,
ProductVariations,
CurrentVariation: CurrentProduct,
initAddToCartButtonClicks,
initAddToCartButtonInitialState,
initAddToCartButtonUpdates,
initDetailInteractions,
initPriceUpdatesOnVariationChange,
PageStates,
});
}),
);

View file

@ -9,6 +9,7 @@ import {
DOM_CONTENUS_ACCORDEON,
DOM_PRIX_PRODUIT,
} from "../constantes/dom.ts";
import { IncoherentDOMError } from "./errors.ts";
import type { DetailEnsemble } from "./types.d.ts";
class ProductPageElements extends Context.Service<
@ -18,8 +19,8 @@ class ProductPageElements extends Context.Service<
Details: HashMap.HashMap<string, DetailEnsemble>;
DetailsButtons: NonEmptyReadonlyArray<HTMLButtonElement>;
DetailsContents: NonEmptyReadonlyArray<HTMLDivElement>;
PageStatesRawJson: HTMLScriptElement;
ProductPrice: HTMLParagraphElement;
ProductRawJson: HTMLScriptElement;
VariationChoiceForm: HTMLFormElement;
VariationSelectors: ReadonlyArray<HTMLSelectElement>;
}
@ -30,8 +31,8 @@ class ProductPageElements extends Context.Service<
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 PageStatesRawJson = yield* getFirstSelectorFromDocument<HTMLScriptElement>("#page-states");
const ProductPrice = yield* getFirstSelectorFromDocument<HTMLParagraphElement>(DOM_PRIX_PRODUIT);
const ProductRawJson = yield* getFirstSelectorFromDocument<HTMLScriptElement>("#product-json");
const VariationChoiceForm = yield* getFirstSelectorFromDocument<HTMLFormElement>("#variation-choice");
const VariationSelectors = yield* pipe(
getAllSelectorFromDocument<HTMLSelectElement>(".selecteur-produit select"),
@ -58,12 +59,12 @@ class ProductPageElements extends Context.Service<
Details,
DetailsButtons,
DetailsContents,
PageStatesRawJson,
ProductPrice,
ProductRawJson,
VariationChoiceForm,
VariationSelectors,
});
}),
}).pipe(Effect.mapError(IncoherentDOMError.fromNoSuchElementError)),
);
}

View file

@ -3,4 +3,5 @@ type DetailEnsemble = {
button: HTMLButtonElement;
content: HTMLDivElement;
};
export { DetailEnsemble };

View file

@ -4,30 +4,17 @@ import { Console, Effect } from "effect";
import ProductPageRuntime from "./page-produit/runtime.ts";
import ProductPageDOM from "./page-produit/service-dom.ts";
/** États utiles pour les scripts de la page. */
type EtatsPage = {
/** L'ID en base de données du Produit. */
idProduit: number;
/** Un nonce pour l'authentification de requêtes API vers le backend WooCommerce. */
nonce: string;
};
// @ts-expect-error -- États injectés par le modèle PHP
const ETATS_PAGE: EtatsPage = _etats;
document.addEventListener("DOMContentLoaded", (): void => {
console.debug("oups");
Effect.gen(function*() {
const DOM = yield* ProductPageDOM;
console.debug("oups");
const effects = Effect.all(
[
DOM.initAddToCartButtonInitialState(),
DOM.initAddToCartButtonUpdates(),
DOM.initAddToCartButtonClicks(),
DOM.initDetailInteractions(),
DOM.initPriceUpdatesOnVariationChange(),
DOM.initAddToCartButtonClicks(),
],
{
concurrency: "unbounded",