ref: continue le travail sur la page Produit

This commit is contained in:
gcch 2026-04-20 17:57:21 +02:00
commit f61ec51d43
9 changed files with 277 additions and 221 deletions

View file

@ -1,12 +1,12 @@
import { Console, Context, Effect, Layer, pipe, Schedule, Schema, SchemaIssue } from "effect";
import { Cause, 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";
import { WooCommerceCart } from "../schemas/cart.ts";
class BodyParsingError extends Schema.TaggedErrorClass<BodyParsingError>()("BodyParsingError", {
cause: Schema.String,
@ -15,12 +15,32 @@ class FetchError extends Schema.TaggedErrorClass<FetchError>()("FetchError", {
cause: Schema.Defect,
}) {}
class ApiRequestError extends Schema.TaggedErrorClass<ApiRequestError>()("ApiRequestError", {
class APIRequestError extends Schema.TaggedErrorClass<APIRequestError>()("APIRequestError", {
reason: Schema.Union([BodyParsingError, FetchError]),
}) {}
class BadRequestError extends Schema.TaggedErrorClass<BadRequestError>()("BadRequestError", {
cause: Schema.Defect,
}){}
class UnauthorizedError extends Schema.TaggedErrorClass<UnauthorizedError>()("UnauthorizedError", {
cause: Schema.Defect,
}){}
class ForbiddenError extends Schema.TaggedErrorClass<ForbiddenError>()("ForbiddenError", {
cause: Schema.Defect,
}){}
class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("NotFoundError", {
cause: Schema.Defect,
}){}
class ServerError extends Schema.TaggedErrorClass<ServerError>()("ServerError", {
cause: Schema.Defect,
}){}
class APIResponseError extends Schema.TaggedErrorClass<APIResponseError>()("APIResponseError", {
reason: Schema.Union([BadRequestError,UnauthorizedError,ForbiddenError,NotFoundError,ServerError]),
}){}
/** Client `fetch` contenant les options et en-têtes de Requêtes pré-renseignées. */
const ApiFetchClient = FetchHttpClient.layer.pipe(
const APIFetchClient = FetchHttpClient.layer.pipe(
Layer.provide(
Layer.succeed(
FetchHttpClient.RequestInit,
@ -36,56 +56,65 @@ const ApiFetchClient = FetchHttpClient.layer.pipe(
),
);
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,
}),
);
class APIClient extends Context.Service<APIClient>()("haikuatelier.fr/APIClient", {
make: Effect.gen(function*() {
// Créé un client HTTP où chaque Requête est imprimée dans la console.
const haikuHTTPClient = pipe(
yield* HttpClient.HttpClient,
// Journalise toutes les Requêtes et Réponses.
HttpClient.tapRequest(request => Console.debug("APIClient", "Request", request)),
HttpClient.tap(response =>
Effect.gen(function*() {
const json = yield* response.json;
yield* Console.debug("APIClient", "Response", response.status, json);
})
),
// 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 AddProductToCart = Effect.fn("AppClient.AddProductToCart")(
function*(nonce: string, productToAdd: CartProduct): Effect.fn.Return<WooCommerceCart, 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, on peut utiliser Unsafe.
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),
);
const response = yield* pipe(
haikuHTTPClient.execute(request),
// TODO: Remplacer Schema.Unknown par un Schéma de l'objet retourné par le backend.
Effect.flatMap(HttpClientResponse.schemaBodyJson(WooCommerceCart)),
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.tapError(error => {
error.stack = "";
error.reason.stack = "";
console.error(error._tag, error.name, error.message, error.reason, error.cause);
return Effect.succeed(error);
}),
);
return response;
},
);
return response;
},
);
return ApiClient.of({ AddProductToCart });
}),
).pipe(Layer.provide(ApiFetchClient));
return { AddProductToCart };
}),
}) {
static readonly Live = Layer.effect(this, this.make).pipe(Layer.provide(APIFetchClient));
}
export { ApiClient, ApiRequestError };
export { APIClient, APIFetchClient, APIRequestError };

View file

@ -0,0 +1,19 @@
import { Optic, Schema } from "effect";
class WooCommerceCart extends Schema.Class<WooCommerceCart>("WooCommerceCart")({
billing_address: Schema.Unknown,
coupons: Schema.Array(Schema.Unknown),
has_calculated_shipping: Schema.Boolean,
items: Schema.Array(Schema.Unknown),
items_count: Schema.Int,
items_weight: Schema.Int,
needs_payment: Schema.Boolean,
needs_shipping: Schema.Boolean,
shipping_address: Schema.Unknown,
shipping_rates: Schema.Array(Schema.Unknown),
totals: Schema.Unknown,
}) {
static readonly _itemsCount = Optic.id<WooCommerceCart>().key("items_count");
}
export { WooCommerceCart };

View file

@ -1,18 +1,18 @@
import { Schema, SchemaIssue } from "effect";
import { NoSuchElementError } from "effect/Cause";
import { SchemaError } from "effect/Schema";
import type { NoSuchElementError } from "effect/Cause";
import type { 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}.`,
});
static readonly fromSchemaError = (error: SchemaError): IncoherentDOMError =>
new IncoherentDOMError({
cause: SchemaIssue.makeFormatterDefault()(error.issue),
});
}
export { IncoherentDOMError };

View file

@ -1,15 +1,13 @@
import { Console, Layer, ManagedRuntime, pipe } from "effect";
import { WooCommerceAPI } from "../../scripts-effect/api-service.ts";
import { ApiClient } from "../../scripts-effect/lib/api.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.provide(ProductPageElements.Live),
Layer.provide(APIClient.Live),
Layer.tapError(error => Console.error("ProductPageRuntime", "Impossible de créer le Layer :", error)),
),
);

View file

@ -1,6 +1,7 @@
// oxlint-disable typescript/dot-notation
import {
Array as FxArray,
Cause,
Console,
Context,
Effect,
@ -15,8 +16,10 @@ import {
} from "effect";
import type { NoSuchElementError } from "effect/Cause";
import type { SchemaError } from "effect/Schema";
import { ApiClient, ApiRequestError } from "../../scripts-effect/lib/api.ts";
import { APIClient } from "../../scripts-effect/lib/api.ts";
import type { APIRequestError } from "../../scripts-effect/lib/api.ts";
import { CartProduct } from "../../scripts-effect/schemas/api.ts";
import { WooCommerceCart } from "../../scripts-effect/schemas/cart.ts";
import { Product, ProductVariation, ProductVariationAttribute } from "../../scripts-effect/schemas/product.ts";
import {
ATTRIBUT_ARIA_CONTROLS,
@ -26,6 +29,7 @@ import {
ATTRIBUT_HIDDEN,
} from "../constantes/dom.ts";
import { lanceAnimationCycleLoading } from "../lib/animations.ts";
import { emetMessageMajBoutonPanier } from "../lib/messages.ts";
import { IncoherentDOMError } from "./errors.ts";
import ProductPageElements from "./service-elements.ts";
import type { DetailEnsemble } from "./types.d.ts";
@ -59,10 +63,10 @@ class ProductPageDOM extends Context.Service<
* Initialise les interactions des Sections de la Description du Produit.
*/
initDetailInteractions: () => Effect.Effect<void, NoSuchElementError>;
/**
/*
* Initialise la mise à jour du Prix affiché en fonction du choix de la Varation de Produit.
*/
initPriceUpdatesOnVariationChange: () => Effect.Effect<void, NoSuchElementError | string>;
initPriceUpdatesOnVariationChange: () => Effect.Effect<void, Error>;
/**
* Replie toutes les sections de la description du Produit.
*/
@ -83,14 +87,13 @@ class ProductPageDOM extends Context.Service<
VariationSelectors,
PageStatesRawJson,
} = yield* ProductPageElements;
const API = yield* ApiClient;
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>());
@ -108,7 +111,6 @@ class ProductPageDOM extends Context.Service<
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)],
@ -154,8 +156,8 @@ class ProductPageDOM extends Context.Service<
return yield* Effect.void;
},
Effect.tapError(Console.error),
// Ouvre toutes les Sections en cas d'erreur.
// oxlint-disable-next-line promise/prefer-await-to-then
Effect.catch(() => toggleAllDetails(true)),
);
@ -171,7 +173,6 @@ class ProductPageDOM extends Context.Service<
onExcessProperty: "error",
}),
Effect.mapError(IncoherentDOMError.fromSchemaError),
Effect.tapError(Console.error),
);
});
@ -203,10 +204,10 @@ class ProductPageDOM extends Context.Service<
ProductPrice.textContent = `${newPrice}`;
return yield* Effect.void;
}, Effect.tapError(Console.error));
});
const recoverFromBackendFailure = Effect.fn("recoverFromBackendFailure")(function*(error: ApiRequestError) {
yield* Console.error(error.reason);
// TODO: Faire une véritable gestion des erreurs.
const recoverFromBackendFailure = Effect.fn("recoverFromBackendFailure")(function*(_error: APIRequestError) {
AddToCartButton.textContent = "Error while adding the Product to the Cart...";
return yield* Effect.void;
});
@ -232,7 +233,7 @@ class ProductPageDOM extends Context.Service<
),
Effect.map(({ id, attributes }) =>
// Les données ont été validées en amont.
Schema.decodeSync(CartProduct)({ id: id, quantity: 1, variation: attributes })
Schema.decodeSync(CartProduct)({ id: id, quantity: 0, variation: attributes })
),
Effect.tap(body => Console.debug("addToCartButtonClickHandler", "requestBody", body)),
);
@ -243,14 +244,17 @@ class ProductPageDOM extends Context.Service<
lanceAnimationCycleLoading(AddToCartButton, 500);
// Exécute la Requête auprès du backend.
yield* API.AddProductToCart(PageStates.nonce, requestBody);
const newCart = yield* API.AddProductToCart(PageStates.nonce, requestBody);
// Met à jour le compteur d'articles du Panier.
const newItemsCount = WooCommerceCart._itemsCount.get(newCart);
emetMessageMajBoutonPanier({ quantiteProduits: newItemsCount });
AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, false);
AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, false);
AddToCartButton.textContent = "Add to cart";
},
Effect.tapError(Console.error),
Effect.catchTag("ApiRequestError", recoverFromBackendFailure),
Effect.catchTag("APIRequestError", recoverFromBackendFailure),
);
const initAddToCartButtonInitialState = Effect.fn("initAddToCartButtonInitialState")(function*() {

View file

@ -1,5 +1,4 @@
import { Array as FxArray, Context, Effect, HashMap, Layer, Option, pipe } from "effect";
import type { NonEmptyReadonlyArray } from "effect/Array";
import type { NoSuchElementError } from "effect/Cause";
import { getAllSelectorFromDocument, getFirstSelectorFromDocument } from "../../scripts-effect/lib/dom.ts";
import {
@ -12,28 +11,21 @@ import {
import { IncoherentDOMError } from "./errors.ts";
import type { DetailEnsemble } from "./types.d.ts";
class ProductPageElements extends Context.Service<
ProductPageElements,
{
AddToCartButton: HTMLButtonElement;
Details: HashMap.HashMap<string, DetailEnsemble>;
DetailsButtons: NonEmptyReadonlyArray<HTMLButtonElement>;
DetailsContents: NonEmptyReadonlyArray<HTMLDivElement>;
PageStatesRawJson: HTMLScriptElement;
ProductPrice: HTMLParagraphElement;
VariationChoiceForm: HTMLFormElement;
VariationSelectors: ReadonlyArray<HTMLSelectElement>;
}
>()("haikuatelier.fr/Produit/ProductPageElements") {
static readonly layer = Layer.effect(
ProductPageElements,
Effect.gen(function*() {
class ProductPageElements
extends Context.Service<ProductPageElements>()("haikuatelier.fr/Product/ProductPageElements", {
make: Effect.gen(function*() {
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 VariationChoiceForm = yield* getFirstSelectorFromDocument<HTMLFormElement>("#variation-choice");
const VariationSelectors = yield* pipe(
getAllSelectorFromDocument<HTMLSelectElement>(".selecteur-produit select"),
Option.orElseSome(() => FxArray.empty<HTMLSelectElement>()),
@ -54,7 +46,7 @@ class ProductPageElements extends Context.Service<
Effect.map(HashMap.fromIterable<string, DetailEnsemble>),
);
return ProductPageElements.of({
return {
AddToCartButton,
Details,
DetailsButtons,
@ -63,9 +55,11 @@ class ProductPageElements extends Context.Service<
ProductPrice,
VariationChoiceForm,
VariationSelectors,
});
};
}).pipe(Effect.mapError(IncoherentDOMError.fromNoSuchElementError)),
);
})
{
static readonly Live = Layer.effect(this, this.make);
}
export default ProductPageElements;

View file

@ -4,4 +4,4 @@ type DetailEnsemble = {
content: HTMLDivElement;
};
export { DetailEnsemble };
export { type DetailEnsemble };