This commit is contained in:
gcch 2026-04-22 11:10:57 +02:00
commit e9f5df223e
13 changed files with 259 additions and 152 deletions

View file

@ -24,13 +24,15 @@ return new Config()
'blank_line_after_namespace' => true, 'blank_line_after_namespace' => true,
'blank_lines_before_namespace' => ['min_line_breaks' => 1, 'max_line_breaks' => 2], 'blank_lines_before_namespace' => ['min_line_breaks' => 1, 'max_line_breaks' => 2],
'cast_spaces' => true, 'cast_spaces' => true,
'class_attributes_separation' => ['elements' => [ 'class_attributes_separation' => [
'elements' => [
'case' => 'none', 'case' => 'none',
'const' => 'none', 'const' => 'none',
'method' => 'one', 'method' => 'one',
'property' => 'one', 'property' => 'one',
'trait_import' => 'none', 'trait_import' => 'none',
]], ],
],
'class_reference_name_casing' => true, 'class_reference_name_casing' => true,
'clean_namespace' => true, 'clean_namespace' => true,
'combine_consecutive_issets' => true, 'combine_consecutive_issets' => true,
@ -93,7 +95,8 @@ return new Config()
'no_trailing_comma_in_singleline' => true, 'no_trailing_comma_in_singleline' => true,
'no_trailing_whitespace_in_comment' => true, 'no_trailing_whitespace_in_comment' => true,
'no_unneeded_braces' => ['namespaces' => true], 'no_unneeded_braces' => ['namespaces' => true],
'no_unneeded_control_parentheses' => ['statements' => [ 'no_unneeded_control_parentheses' => [
'statements' => [
'break', 'break',
'clone', 'clone',
'continue', 'continue',
@ -104,7 +107,8 @@ return new Config()
'switch_case', 'switch_case',
'yield', 'yield',
'yield_from', 'yield_from',
]], ],
],
'no_unneeded_final_method' => true, 'no_unneeded_final_method' => true,
'no_unneeded_import_alias' => true, 'no_unneeded_import_alias' => true,
'no_unreachable_default_argument_value' => true, 'no_unreachable_default_argument_value' => true,
@ -135,11 +139,9 @@ return new Config()
'pow_to_exponentiation' => true, 'pow_to_exponentiation' => true,
'protected_to_private' => true, 'protected_to_private' => true,
'psr_autoloading' => true, 'psr_autoloading' => true,
'random_api_migration' => ['replacements' => [ 'random_api_migration' => [
'getrandmax' => 'mt_getrandmax', 'replacements' => ['getrandmax' => 'mt_getrandmax', 'rand' => 'mt_rand', 'srand' => 'mt_srand'],
'rand' => 'mt_rand', ],
'srand' => 'mt_srand',
]],
'return_assignment' => true, 'return_assignment' => true,
'self_accessor' => true, 'self_accessor' => true,
'self_static_accessor' => true, 'self_static_accessor' => true,
@ -214,7 +216,8 @@ return new Config()
// The type of @return annotations of methods returning a reference to itself must the configured one. // The type of @return annotations of methods returning a reference to itself must the configured one.
'phpdoc_return_self_reference' => true, 'phpdoc_return_self_reference' => true,
// Scalar types should always be written in the same form. int not integer, bool not boolean, float not real or double. // Scalar types should always be written in the same form. int not integer, bool not boolean, float not real or double.
'phpdoc_scalar' => ['types' => [ 'phpdoc_scalar' => [
'types' => [
'boolean', 'boolean',
'callback', 'callback',
'double', 'double',
@ -224,7 +227,8 @@ return new Config()
'no-return', 'no-return',
'real', 'real',
'str', 'str',
]], ],
],
// Annotations in PHPDoc should be grouped together so that annotations of the same type immediately follow each other. Annotations of a different type are separated by a single blank line. // Annotations in PHPDoc should be grouped together so that annotations of the same type immediately follow each other. Annotations of a different type are separated by a single blank line.
'phpdoc_separation' => [ 'phpdoc_separation' => [
'groups' => [ 'groups' => [

View file

@ -252,6 +252,11 @@ em {
font-style: italic; font-style: italic;
} }
code {
font-family: monospace;
font-size: 0.9rem;
}
/* Mixins Sass */ /* Mixins Sass */
/* /*
* Réinitialisation des styles des <button>. * Réinitialisation des styles des <button>.
@ -339,6 +344,50 @@ button.bouton-retour-haut[data-actif] {
} }
} }
dialog {
z-index: 999;
flex-flow: column nowrap;
place-self: center center;
padding: var(--espace-l);
opacity: 0;
background: white;
transition: display 0.3s, opacity 0.3s, overlay 0.3s;
transition-behavior: allow-discrete;
}
dialog::backdrop {
background-color: transparent;
transition: background-color 0.3s, display 0.3s, overlay 0.3s;
transition-behavior: allow-discrete;
}
dialog:open {
display: flex;
opacity: 1;
}
dialog:open::backdrop {
background-color: var(--couleur-fond);
}
dialog * + * {
margin-block-start: var(--espace-m);
}
dialog p {
max-inline-size: 50ch;
}
dialog button {
align-self: end;
inline-size: fit-content;
padding: var(--espace-s);
}
@starting-style {
dialog:open {
opacity: 0;
}
}
@starting-style {
dialog:open::backdrop {
background-color: transparent;
}
}
fieldset { fieldset {
all: initial; all: initial;
display: flex; display: flex;
@ -587,6 +636,15 @@ ul.avec-puce-cercle a {
) url("/app/themes/haiku-atelier-2024/assets/img/icons/dot.svg"); /* 2 */ ) url("/app/themes/haiku-atelier-2024/assets/img/icons/dot.svg"); /* 2 */
} }
video {
display: block;
inline-size: 100%;
min-inline-size: 100%;
block-size: 100%;
min-block-size: inherit;
object-fit: cover;
}
/* * Styles pour un bandeau défilant. */ /* * Styles pour un bandeau défilant. */
.bandeau { .bandeau {
overflow: hidden; overflow: hidden;
@ -1537,7 +1595,6 @@ body:has(#menu-mobile:not([aria-hidden="true"])) {
padding: var(--espace-s) var(--espace-m); padding: var(--espace-s) var(--espace-m);
border-top: 1px solid var(--couleur-noir); border-top: 1px solid var(--couleur-noir);
font-size: 0.8rem; font-size: 0.8rem;
background: var(--couleur-jaune);
} }
#pied-de-page .zone-menu-navigation-secondaire { #pied-de-page .zone-menu-navigation-secondaire {
justify-self: start; justify-self: start;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -154,12 +154,14 @@ $session_checkout_stripe = Session::create([
'mode' => 'payment', 'mode' => 'payment',
'success_url' => $urls['succes_commande'] . '?session_id={CHECKOUT_SESSION_ID}', 'success_url' => $urls['succes_commande'] . '?session_id={CHECKOUT_SESSION_ID}',
'metadata' => ['order_id' => $order_id, 'order_key' => $order_key], 'metadata' => ['order_id' => $order_id, 'order_key' => $order_key],
'shipping_options' => [['shipping_rate_data' => [ 'shipping_options' => [[
'shipping_rate_data' => [
'display_name' => $methode_livraison['nom'], 'display_name' => $methode_livraison['nom'],
'fixed_amount' => ['amount' => $methode_livraison['cout'], 'currency' => 'EUR'], 'fixed_amount' => ['amount' => $methode_livraison['cout'], 'currency' => 'EUR'],
'tax_behavior' => 'inclusive', 'tax_behavior' => 'inclusive',
'type' => 'fixed_amount', 'type' => 'fixed_amount',
]]], ],
]],
], ['idempotency_key' => Uuid::v4()]); ], ['idempotency_key' => Uuid::v4()]);
// echo json_encode($session_checkout_stripe); // echo json_encode($session_checkout_stripe);
header('HTTP/1.1 303 See Other'); header('HTTP/1.1 303 See Other');

View file

@ -26,5 +26,10 @@ em {
font-style: italic; font-style: italic;
} }
code {
font-family: monospace;
font-size: 0.9rem;
}
/* Mixins Sass */ /* Mixins Sass */
// TODO: Créer une mixin pour le style "gris avec lettres espacées" // TODO: Créer une mixin pour le style "gris avec lettres espacées"

View file

@ -49,4 +49,4 @@ class WooCommerceAPI extends Context.Service<
); );
} }
export { APIError, WooCommerceAPI }; export { WooCommerceAPI };

View file

@ -1,44 +1,35 @@
import { Cause, Console, Context, Effect, Layer, pipe, Schedule, Schema, SchemaIssue } from "effect"; import { Console, Context, Effect, Layer, Match, pipe, Schedule, Schema, SchemaIssue } from "effect";
import { SchemaError } from "effect/Schema";
import { import {
FetchHttpClient, FetchHttpClient,
HttpClient, HttpClient,
HttpClientError,
HttpClientRequest, HttpClientRequest,
HttpClientResponse, HttpClientResponse,
} from "effect/unstable/http"; } from "effect/unstable/http";
import { HttpClientErrorSchema } from "effect/unstable/http/HttpClientError";
import type { CartProduct } from "../schemas/api.ts"; import type { CartProduct } from "../schemas/api.ts";
import { WooCommerceCart } from "../schemas/cart.ts"; import { WooCommerceCart } from "../schemas/cart.ts";
class BodyParsingError extends Schema.TaggedErrorClass<BodyParsingError>()("BodyParsingError", { /** Le nombre maximal d'essais pour une Requête. */
cause: Schema.String, const MAX_RETRIES = 3;
}) {} /** Le temps d'attente avant de réessayer une Requête. */
class FetchError extends Schema.TaggedErrorClass<FetchError>()("FetchError", { const RETRY_WAIT_TIME = "1 seconds";
cause: Schema.Defect,
}) {}
/** Décrit une Erreur survenue au traitement d'une `Request`. */
class APIRequestError extends Schema.TaggedErrorClass<APIRequestError>()("APIRequestError", { class APIRequestError extends Schema.TaggedErrorClass<APIRequestError>()("APIRequestError", {
reason: Schema.Union([BodyParsingError, FetchError]), message: Schema.String,
}) {} cause: Schema.Union([Schema.Defect, HttpClientErrorSchema]),
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", { class APIResponseError extends Schema.TaggedErrorClass<APIResponseError>()("APIResponseError", {
reason: Schema.Union([BadRequestError,UnauthorizedError,ForbiddenError,NotFoundError,ServerError]), message: Schema.String,
cause: Schema.Union([Schema.Defect, HttpClientErrorSchema]),
}) {} }) {}
type APIError = APIRequestError | APIResponseError;
/** Client `fetch` contenant les options et en-têtes de Requêtes pré-renseignées. */ /** 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.provide(
@ -61,24 +52,51 @@ class APIClient extends Context.Service<APIClient>()("haikuatelier.fr/APIClient"
// Créé un client HTTP où chaque Requête est imprimée dans la console. // Créé un client HTTP où chaque Requête est imprimée dans la console.
const haikuHTTPClient = pipe( const haikuHTTPClient = pipe(
yield* HttpClient.HttpClient, 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.tapRequest(request => Console.debug("APIClient", "Request", request)),
HttpClient.tap(response => // En cas de code HTTP indiquant un échec, générer une erreur.
Effect.gen(function*() { HttpClient.filterStatusOk,
const json = yield* response.json;
yield* Console.debug("APIClient", "Response", response.status, json);
})
),
// Définis une politique d'essai. // Définis une politique d'essai.
HttpClient.retryTransient({ HttpClient.retryTransient({
retryOn: "errors-only", retryOn: "errors-only",
schedule: Schedule.exponential("1 seconds"), schedule: Schedule.exponential(RETRY_WAIT_TIME),
times: 3, times: MAX_RETRIES,
}), }),
); );
const matchAPIError = (error: HttpClientError.HttpClientError | SchemaError): APIError => {
if (error._tag === "SchemaError") {
return new APIRequestError({
message: `Erreur lors du parsage du corps de la Requête :${SchemaIssue.makeFormatterDefault()(error.issue)}`,
cause: error,
});
} else {
return Match.typeTags<HttpClientError.HttpClientErrorReason, APIError>()({
TransportError: (cause): APIError =>
new APIRequestError({
cause,
message: "Un problème réseau empêche l'exécution de la Requête",
}),
EncodeError: cause => new APIRequestError({ cause, message: "Le corps de la Requête ne peut être lu" }),
InvalidUrlError: cause => new APIRequestError({ cause, message: "L'URL de la Requête n'est pas valide" }),
StatusCodeError: cause =>
new APIResponseError({ cause, message: "Le code HTTP de la Réponse correspond à un échec" }),
DecodeError: cause => new APIResponseError({ cause, message: "Le corps de la Réponse ne peut être lu" }),
EmptyBodyError: cause => new APIResponseError({ cause, message: "Un corps vide ne peut être lu" }),
})(error.reason);
}
};
const printErrorAsSuccinctMessage = (error: APIError): Effect.Effect<APIError> =>
Effect.gen(function*() {
yield* Console.error(
`${error.name} - ${error.message} : ${error.cause?._tag ?? ""} - ${error.cause?.description ?? ""}.`,
);
return yield* Effect.succeed(error);
});
const AddProductToCart = Effect.fn("AppClient.AddProductToCart")( const AddProductToCart = Effect.fn("AppClient.AddProductToCart")(
function*(nonce: string, productToAdd: CartProduct): Effect.fn.Return<WooCommerceCart, APIRequestError> { function*(nonce: string, productToAdd: CartProduct): Effect.fn.Return<WooCommerceCart, APIError> {
const request = pipe( const request = pipe(
HttpClientRequest.post(`/wp-json/wc/store/cart/add-item`), HttpClientRequest.post(`/wp-json/wc/store/cart/add-item`),
HttpClientRequest.setHeader("Nonce", nonce), HttpClientRequest.setHeader("Nonce", nonce),
@ -88,23 +106,9 @@ class APIClient extends Context.Service<APIClient>()("haikuatelier.fr/APIClient"
const response = yield* pipe( const response = yield* pipe(
haikuHTTPClient.execute(request), haikuHTTPClient.execute(request),
// TODO: Remplacer Schema.Unknown par un Schéma de l'objet retourné par le backend.
Effect.flatMap(HttpClientResponse.schemaBodyJson(WooCommerceCart)), Effect.flatMap(HttpClientResponse.schemaBodyJson(WooCommerceCart)),
Effect.mapError(error => { Effect.mapError(error => matchAPIError(error)),
if (error._tag === "SchemaError") { Effect.tapError(error => printErrorAsSuccinctMessage(error)),
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;
@ -117,4 +121,5 @@ class APIClient extends Context.Service<APIClient>()("haikuatelier.fr/APIClient"
static readonly Live = Layer.effect(this, this.make).pipe(Layer.provide(APIFetchClient)); static readonly Live = Layer.effect(this, this.make).pipe(Layer.provide(APIFetchClient));
} }
export { APIClient, APIFetchClient, APIRequestError }; export { APIClient, APIFetchClient, APIRequestError, APIResponseError };
export type { APIError };

View file

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

View file

@ -1,7 +1,6 @@
// oxlint-disable typescript/dot-notation // oxlint-disable typescript/dot-notation
import { import {
Array as FxArray, Array as FxArray,
Cause,
Console, Console,
Context, Context,
Effect, Effect,
@ -13,11 +12,11 @@ import {
Schema, Schema,
SchemaIssue, SchemaIssue,
Stream, Stream,
SubscriptionRef,
} from "effect"; } from "effect";
import type { NoSuchElementError } from "effect/Cause";
import type { SchemaError } from "effect/Schema"; import type { SchemaError } from "effect/Schema";
import { APIClient } from "../../scripts-effect/lib/api.ts"; import { APIClient } from "../../scripts-effect/lib/api.ts";
import type { APIRequestError } from "../../scripts-effect/lib/api.ts"; import type { APIError } from "../../scripts-effect/lib/api.ts";
import { CartProduct } from "../../scripts-effect/schemas/api.ts"; import { CartProduct } from "../../scripts-effect/schemas/api.ts";
import { WooCommerceCart } from "../../scripts-effect/schemas/cart.ts"; import { WooCommerceCart } from "../../scripts-effect/schemas/cart.ts";
import { Product, ProductVariation, ProductVariationAttribute } from "../../scripts-effect/schemas/product.ts"; import { Product, ProductVariation, ProductVariationAttribute } from "../../scripts-effect/schemas/product.ts";
@ -28,10 +27,10 @@ import {
ATTRIBUT_DESACTIVE, ATTRIBUT_DESACTIVE,
ATTRIBUT_HIDDEN, ATTRIBUT_HIDDEN,
} from "../constantes/dom.ts"; } from "../constantes/dom.ts";
import { lanceAnimationCycleLoading } from "../lib/animations.ts";
import { emetMessageMajBoutonPanier } from "../lib/messages.ts"; import { emetMessageMajBoutonPanier } from "../lib/messages.ts";
import { IncoherentDOMError } from "./errors.ts"; import { IncoherentDOMError } from "./errors.ts";
import ProductPageElements from "./service-elements.ts"; import ProductPageElements from "./service-elements.ts";
import ProductPageMessages from "./service-messages.ts";
import type { DetailEnsemble } from "./types.d.ts"; import type { DetailEnsemble } from "./types.d.ts";
const PageStatesSchema = Schema.Struct({ const PageStatesSchema = Schema.Struct({
@ -48,36 +47,10 @@ class InvalidPageStateError extends Schema.TaggedErrorClass<InvalidPageStateErro
}); });
} }
class ProductPageDOM extends Context.Service< class ProductPageDOM extends Context.Service<ProductPageDOM>()(
ProductPageDOM, "haikuatelier.fr/Produit/ProductPageDOM",
{ {
/** make: Effect.gen(function*() {
* 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*() {
const { const {
AddToCartButton, AddToCartButton,
Details, Details,
@ -87,6 +60,7 @@ class ProductPageDOM extends Context.Service<
VariationSelectors, VariationSelectors,
PageStatesRawJson, PageStatesRawJson,
} = yield* ProductPageElements; } = yield* ProductPageElements;
const { AddToCartButtonText } = yield* ProductPageMessages;
const API = yield* APIClient; const API = yield* APIClient;
const PageStates = yield* pipe( const PageStates = yield* pipe(
@ -98,7 +72,7 @@ class ProductPageDOM extends Context.Service<
const CurrentProduct = yield* Ref.make(Option.none<ProductVariation>()); const CurrentProduct = yield* Ref.make(Option.none<ProductVariation>());
const onFormChangeHandler = Effect.fn("onFormChangeHandler")(function*(evt: Event) { const formChangeHandler = Effect.fn("formChangeHandler")(function*(evt: Event) {
// La cible ne peut qu'être un Formulaire. // La cible ne peut qu'être un Formulaire.
const target: HTMLFormElement = evt.target as HTMLFormElement; const target: HTMLFormElement = evt.target as HTMLFormElement;
const isClickAllowed = target.checkValidity() === false; const isClickAllowed = target.checkValidity() === false;
@ -121,7 +95,7 @@ class ProductPageDOM extends Context.Service<
); );
}); });
const onDetailButtonClickHandler = Effect.fn("onDetailButtonClickHandler")( const detailButtonClickHandler = Effect.fn("detailButtonClickHandler")(
function*(evt: Event) { function*(evt: Event) {
// Empêche la pollution de l'historique de navigation // Empêche la pollution de l'historique de navigation
evt.preventDefault(); evt.preventDefault();
@ -176,17 +150,17 @@ class ProductPageDOM extends Context.Service<
); );
}); });
const onVariationChangeHandler = Effect.fn("onVariationChangeHandler")(function*() { const variationChangeHandler = Effect.fn("variationChangeHandler")(function*() {
yield* Console.log("onVariationChangeHandler"); yield* Console.log("variationChangeHandler");
// Ne fais rien si le Formulaire n'est pas valide. // Ne fais rien si le Formulaire n'est pas valide.
if (VariationChoiceForm.checkValidity() === false) { if (VariationChoiceForm.checkValidity() === false) {
yield* Console.debug("onVariationChangeHandler", "Le formulaire est invalide."); yield* Console.debug("variationChangeHandler", "Le formulaire est invalide.");
return yield* Effect.void; return yield* Effect.void;
} }
const equivalence = Schema.toEquivalence(Schema.Array(ProductVariationAttribute)); const equivalence = Schema.toEquivalence(Schema.Array(ProductVariationAttribute));
const chosenProductAttributes = yield* getChosenProductAttributesFromDOM(); const chosenProductAttributes = yield* getChosenProductAttributesFromDOM();
yield* Console.debug("onVariationChangeHandler", "chosenProductAttributes", chosenProductAttributes); yield* Console.debug("variationChangeHandler", "chosenProductAttributes", chosenProductAttributes);
const chosenVariation = yield* pipe( const chosenVariation = yield* pipe(
FxArray.findFirst( FxArray.findFirst(
PageStates.product.variations, PageStates.product.variations,
@ -195,7 +169,7 @@ class ProductPageDOM extends Context.Service<
Effect.fromOption, Effect.fromOption,
Effect.mapError(error => new Error("Impossible de trouver la variation demandée.", { cause: error })), Effect.mapError(error => new Error("Impossible de trouver la variation demandée.", { cause: error })),
); );
yield* Console.debug("onVariationChangeHandler", "chosenVariation", chosenVariation); yield* Console.debug("variationChangeHandler", "chosenVariation", chosenVariation);
// Met à jour la valeur de la Variation choisie dans le Service. // Met à jour la valeur de la Variation choisie dans le Service.
yield* Ref.set(Option.some(chosenVariation))(CurrentProduct); yield* Ref.set(Option.some(chosenVariation))(CurrentProduct);
@ -207,8 +181,18 @@ class ProductPageDOM extends Context.Service<
}); });
// TODO: Faire une véritable gestion des erreurs. // TODO: Faire une véritable gestion des erreurs.
const recoverFromBackendFailure = Effect.fn("recoverFromBackendFailure")(function*(_error: APIRequestError) { const recoverFromBackendFailure = Effect.fn("recoverFromBackendFailure")(function*(error: APIError) {
AddToCartButton.textContent = "Error while adding the Product to the Cart..."; AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, false);
AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, false);
// Affiche un message d'erreur sommaire à l'utilisateur puis réinitialise le texte.
yield* SubscriptionRef.set(AddToCartButtonText, "Error!");
document.querySelector("#cart-error").showModal();
document.querySelector("#cart-error code").textContent = `${error.name} ${error.message}`;
yield* SubscriptionRef.set(AddToCartButtonText, "Add to cart").pipe(Effect.delay("5 seconds"));
return yield* Effect.void; return yield* Effect.void;
}); });
@ -241,7 +225,7 @@ class ProductPageDOM extends Context.Service<
// Désactive les interactions le temps de la requête. // Désactive les interactions le temps de la requête.
AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, true); AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, true);
AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, true); AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, true);
lanceAnimationCycleLoading(AddToCartButton, 500); // lanceAnimationCycleLoading(AddToCartButton, 500);
// Exécute la Requête auprès du backend. // Exécute la Requête auprès du backend.
const newCart = yield* API.AddProductToCart(PageStates.nonce, requestBody); const newCart = yield* API.AddProductToCart(PageStates.nonce, requestBody);
@ -252,9 +236,12 @@ class ProductPageDOM extends Context.Service<
AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, false); AddToCartButton.toggleAttribute(ATTRIBUT_DESACTIVE, false);
AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, false); AddToCartButton.toggleAttribute(ATTRIBUT_CHARGEMENT, false);
AddToCartButton.textContent = "Add to cart"; yield* SubscriptionRef.set(AddToCartButtonText, "Add to cart");
}, },
Effect.catchTag("APIRequestError", recoverFromBackendFailure), Effect.catchTags({
APIResponseError: error => recoverFromBackendFailure(error),
APIRequestError: error => recoverFromBackendFailure(error),
}),
); );
const initAddToCartButtonInitialState = Effect.fn("initAddToCartButtonInitialState")(function*() { const initAddToCartButtonInitialState = Effect.fn("initAddToCartButtonInitialState")(function*() {
@ -278,7 +265,7 @@ class ProductPageDOM extends Context.Service<
function*(): Effect.fn.Return<void> { function*(): Effect.fn.Return<void> {
return yield* pipe( return yield* pipe(
Stream.fromEventListener(VariationChoiceForm, "change"), Stream.fromEventListener(VariationChoiceForm, "change"),
Stream.tap(onFormChangeHandler), Stream.tap(formChangeHandler),
Stream.runDrain, Stream.runDrain,
); );
}, },
@ -295,7 +282,7 @@ class ProductPageDOM extends Context.Service<
const initPriceUpdatesOnVariationChange = Effect.fn("initPriceUpdatesOnVariationChange")(function*() { const initPriceUpdatesOnVariationChange = Effect.fn("initPriceUpdatesOnVariationChange")(function*() {
return yield* pipe( return yield* pipe(
Stream.fromEventListener(VariationChoiceForm, "change"), Stream.fromEventListener(VariationChoiceForm, "change"),
Stream.tap(onVariationChangeHandler), Stream.tap(variationChangeHandler),
Stream.runDrain, Stream.runDrain,
); );
}); });
@ -306,14 +293,14 @@ class ProductPageDOM extends Context.Service<
FxArray.map( FxArray.map(
DetailsButtons, DetailsButtons,
(button: HTMLButtonElement) => (button: HTMLButtonElement) =>
pipe(Stream.fromEventListener(button, "click"), Stream.tap(onDetailButtonClickHandler)), pipe(Stream.fromEventListener(button, "click"), Stream.tap(detailButtonClickHandler)),
), ),
Stream.mergeAll({ concurrency: "unbounded" }), Stream.mergeAll({ concurrency: "unbounded" }),
Stream.runDrain, Stream.runDrain,
); );
}); });
return ProductPageDOM.of({ return {
CurrentVariation: CurrentProduct, CurrentVariation: CurrentProduct,
initAddToCartButtonClicks, initAddToCartButtonClicks,
initAddToCartButtonInitialState, initAddToCartButtonInitialState,
@ -321,9 +308,11 @@ class ProductPageDOM extends Context.Service<
initDetailInteractions, initDetailInteractions,
initPriceUpdatesOnVariationChange, initPriceUpdatesOnVariationChange,
PageStates, PageStates,
}); };
}), }),
); },
) {
static readonly Live = Layer.effect(this, this.make);
} }
export default ProductPageDOM; 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

@ -1,12 +1,14 @@
// Scripts pour la Page Produit // Scripts pour la Page Produit
import { Console, Effect } from "effect"; import { Cause, Console, Effect } from "effect";
import ProductPageRuntime from "./page-produit/runtime.ts"; import ProductPageRuntime from "./page-produit/runtime.ts";
import ProductPageDOM from "./page-produit/service-dom.ts"; import ProductPageDOM from "./page-produit/service-dom.ts";
import ProductPageMessages from "./page-produit/service-messages.ts";
document.addEventListener("DOMContentLoaded", (): void => { document.addEventListener("DOMContentLoaded", (): void => {
Effect.gen(function*() { Effect.gen(function*() {
const DOM = yield* ProductPageDOM; const DOM = yield* ProductPageDOM;
const Messages = yield* ProductPageMessages;
const effects = Effect.all( const effects = Effect.all(
[ [
@ -15,14 +17,19 @@ document.addEventListener("DOMContentLoaded", (): void => {
DOM.initAddToCartButtonClicks(), DOM.initAddToCartButtonClicks(),
DOM.initDetailInteractions(), DOM.initDetailInteractions(),
DOM.initPriceUpdatesOnVariationChange(), DOM.initPriceUpdatesOnVariationChange(),
Messages.initAddToCartButtonUpdates(),
], ],
{ {
concurrency: "unbounded", concurrency: "unbounded",
}, },
); );
yield* effects.pipe(Effect.tapCause(Console.error)); yield* effects.pipe(Effect.tapCause(cause => Console.error(Cause.pretty(cause))));
}).pipe(ProductPageRuntime.runFork); }).pipe(ProductPageRuntime.runFork);
document.querySelector("#cart-error button").addEventListener("click", (): void => {
document.querySelector("#cart-error").close();
});
}); });
// const ajouteProduitAuPanier = (event: MouseEvent): void => { // const ajouteProduitAuPanier = (event: MouseEvent): void => {

View file

@ -87,3 +87,11 @@
{% endif %} {% endif %}
</div> </div>
</aside> </aside>
<dialog id="cart-error">
<p>An error happened! Please retry.</p>
<code></code>
<p>If that happens again, please contact us with the content or a capture of the error.</p>
<button class="bouton-blanc-sur-noir" autofocus>Close</button>
</dialog>