ref: continue le travail sur la page Produit
This commit is contained in:
parent
922a66d5bc
commit
47b66eeb34
9 changed files with 216 additions and 170 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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*() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ type DetailEnsemble = {
|
|||
content: HTMLDivElement;
|
||||
};
|
||||
|
||||
export { DetailEnsemble };
|
||||
export { type DetailEnsemble };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue