2026-04-22

This commit is contained in:
gcch 2026-04-22 11:10:57 +02:00
commit e7c3dda8af
8 changed files with 167 additions and 138 deletions

View file

@ -1,4 +1,4 @@
import { Cause, Console, Context, Effect, Layer, pipe, Schedule, Schema, SchemaIssue } from "effect";
import { Console, Context, Effect, Layer, pipe, Schedule, Schema, SchemaIssue } from "effect";
import {
FetchHttpClient,
HttpClient,
@ -8,36 +8,44 @@ import {
import type { CartProduct } from "../schemas/api.ts";
import { WooCommerceCart } from "../schemas/cart.ts";
const MAX_RETRIES = 3;
const RETRY_TIME = "1 seconds";
class BodyParsingError extends Schema.TaggedErrorClass<BodyParsingError>()("BodyParsingError", {
cause: Schema.String,
}) {}
class FetchError extends Schema.TaggedErrorClass<FetchError>()("FetchError", {
cause: Schema.Defect,
}) {}
class BadRequestError extends Schema.TaggedErrorClass<BadRequestError>()("BadRequestError", {
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 UnauthorizedError extends Schema.TaggedErrorClass<UnauthorizedError>()("UnauthorizedError", {
cause: Schema.Defect,
}) {}
class BadStatusCodeError extends Schema.TaggedErrorClass<BadStatusCodeError>()("BadStatusCodeError", {
reason: Schema.Union([BadRequestError, ForbiddenError, NotFoundError, ServerError, UnauthorizedError])
}){}
/** Décrit une Erreur survenue au traitement d'une `Request`. */
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,
}){}
/** Décrit une Erreur survenue au traitement d'une `Response`. */
class APIResponseError extends Schema.TaggedErrorClass<APIResponseError>()("APIResponseError", {
reason: Schema.Union([BadRequestError,UnauthorizedError,ForbiddenError,NotFoundError,ServerError]),
}){}
reason: Schema.Union([BadStatusCodeError, BodyParsingError]),
}) {}
/** Client `fetch` contenant les options et en-têtes de Requêtes pré-renseignées. */
const APIFetchClient = FetchHttpClient.layer.pipe(
@ -61,19 +69,15 @@ class APIClient extends Context.Service<APIClient>()("haikuatelier.fr/APIClient"
// 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.
// Journalise toutes les Requêtes.
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);
})
),
// En cas de code HTTP indiquant un échec, générer une erreur.
HttpClient.filterStatusOk,
// Définis une politique d'essai.
HttpClient.retryTransient({
retryOn: "errors-only",
schedule: Schedule.exponential("1 seconds"),
times: 3,
schedule: Schedule.exponential(RETRY_TIME),
times: MAX_RETRIES,
}),
);

View file

@ -2,10 +2,12 @@ import { Console, Layer, ManagedRuntime, pipe } from "effect";
import { APIClient } from "../../scripts-effect/lib/api.ts";
import ProductPageDOM from "./service-dom.ts";
import ProductPageElements from "./service-elements.ts";
import ProductPageMessages from "./service-messages.ts";
const ProductPageRuntime = ManagedRuntime.make(
pipe(
ProductPageDOM.layer,
ProductPageDOM.Live,
Layer.provideMerge(ProductPageMessages.Live),
Layer.provide(ProductPageElements.Live),
Layer.provide(APIClient.Live),
Layer.tapError(error => Console.error("ProductPageRuntime", "Impossible de créer le Layer :", error)),

View file

@ -1,7 +1,6 @@
// oxlint-disable typescript/dot-notation
import {
Array as FxArray,
Cause,
Console,
Context,
Effect,
@ -13,8 +12,8 @@ import {
Schema,
SchemaIssue,
Stream,
SubscriptionRef,
} from "effect";
import type { NoSuchElementError } from "effect/Cause";
import type { SchemaError } from "effect/Schema";
import { APIClient } from "../../scripts-effect/lib/api.ts";
import type { APIRequestError } from "../../scripts-effect/lib/api.ts";
@ -32,6 +31,7 @@ import { lanceAnimationCycleLoading } from "../lib/animations.ts";
import { emetMessageMajBoutonPanier } from "../lib/messages.ts";
import { IncoherentDOMError } from "./errors.ts";
import ProductPageElements from "./service-elements.ts";
import ProductPageMessages from "./service-messages.ts";
import type { DetailEnsemble } from "./types.d.ts";
const PageStatesSchema = Schema.Struct({
@ -48,36 +48,10 @@ class InvalidPageStateError extends Schema.TaggedErrorClass<InvalidPageStateErro
});
}
class ProductPageDOM extends Context.Service<
ProductPageDOM,
class ProductPageDOM extends Context.Service<ProductPageDOM>()(
"haikuatelier.fr/Produit/ProductPageDOM",
{
/**
* 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>;
/*
* Initialise la mise à jour du Prix affiché en fonction du choix de la Varation de Produit.
*/
initPriceUpdatesOnVariationChange: () => Effect.Effect<void, Error>;
/**
* Replie toutes les sections de la description du Produit.
*/
initAddToCartButtonClicks: () => unknown;
PageStates: typeof PageStatesSchema.Type;
CurrentVariation: Ref.Ref<Option.Option<ProductVariation>>;
}
>()("haikuatelier.fr/Produit/ProductPageDOM") {
static readonly layer = Layer.effect(
ProductPageDOM,
Effect.gen(function*() {
make: Effect.gen(function*() {
const {
AddToCartButton,
Details,
@ -87,6 +61,7 @@ class ProductPageDOM extends Context.Service<
VariationSelectors,
PageStatesRawJson,
} = yield* ProductPageElements;
const { AddToCartButtonText } = yield* ProductPageMessages;
const API = yield* APIClient;
const PageStates = yield* pipe(
@ -208,7 +183,10 @@ class ProductPageDOM extends Context.Service<
// 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...";
AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, false);
AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, false);
yield* SubscriptionRef.set(AddToCartButtonText, "Error!");
return yield* Effect.void;
});
@ -241,7 +219,7 @@ class ProductPageDOM extends Context.Service<
// Désactive les interactions le temps de la requête.
AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, true);
AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, true);
lanceAnimationCycleLoading(AddToCartButton, 500);
// lanceAnimationCycleLoading(AddToCartButton, 500);
// Exécute la Requête auprès du backend.
const newCart = yield* API.AddProductToCart(PageStates.nonce, requestBody);
@ -252,7 +230,7 @@ class ProductPageDOM extends Context.Service<
AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, false);
AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, false);
AddToCartButton.textContent = "Add to cart";
yield* SubscriptionRef.set(AddToCartButtonText, "Add to cart");
},
Effect.catchTag("APIRequestError", recoverFromBackendFailure),
);
@ -313,7 +291,7 @@ class ProductPageDOM extends Context.Service<
);
});
return ProductPageDOM.of({
return {
CurrentVariation: CurrentProduct,
initAddToCartButtonClicks,
initAddToCartButtonInitialState,
@ -321,9 +299,11 @@ class ProductPageDOM extends Context.Service<
initDetailInteractions,
initPriceUpdatesOnVariationChange,
PageStates,
});
};
}),
);
},
) {
static readonly Live = Layer.effect(this, this.make);
}
export default ProductPageDOM;

View file

@ -0,0 +1,28 @@
import { Context, Effect, Layer, pipe, Stream, SubscriptionRef } from "effect";
import ProductPageElements from "./service-elements.ts";
class ProductPageMessages extends Context.Service<ProductPageMessages>()("haikuatelier.fr/Product/Messages", {
make: Effect.gen(function*() {
const { AddToCartButton } = yield* ProductPageElements;
const AddToCartButtonText = yield* SubscriptionRef.make("Add to cart");
// const AddToCartErrorText = yield* SubscriptionRef.make<Option.Option<string>>(Option.none());
const initAddToCartButtonUpdates = Effect.fn("initAddToCartButtonUpdates")(function*() {
return yield* pipe(
SubscriptionRef.changes(AddToCartButtonText),
Stream.tap(newText => {
AddToCartButton.textContent = newText;
return Effect.succeed(newText);
}),
Stream.runDrain,
);
});
return { AddToCartButtonText, initAddToCartButtonUpdates };
}),
}) {
static readonly Live = Layer.effect(this, this.make);
}
export default ProductPageMessages;

View file

@ -3,10 +3,12 @@
import { Console, Effect } from "effect";
import ProductPageRuntime from "./page-produit/runtime.ts";
import ProductPageDOM from "./page-produit/service-dom.ts";
import ProductPageMessages from "./page-produit/service-messages.ts";
document.addEventListener("DOMContentLoaded", (): void => {
Effect.gen(function*() {
const DOM = yield* ProductPageDOM;
const Messages = yield* ProductPageMessages;
const effects = Effect.all(
[
@ -15,6 +17,7 @@ document.addEventListener("DOMContentLoaded", (): void => {
DOM.initAddToCartButtonClicks(),
DOM.initDetailInteractions(),
DOM.initPriceUpdatesOnVariationChange(),
Messages.initAddToCartButtonUpdates(),
],
{
concurrency: "unbounded",