From 56af75707f9531089faa4fc14f486042f04e7573 Mon Sep 17 00:00:00 2001 From: gcch Date: Wed, 29 Apr 2026 10:45:49 +0200 Subject: [PATCH] ref(boutique) wip scripts sous forme Effect --- .../src/scripts-effect/lib/api.ts | 17 +- .../src/scripts-effect/schemas/api.ts | 58 +++++- .../src/scripts-effect/schemas/product.ts | 3 + .../src/scripts/page-boutique/runtime.ts | 17 ++ .../src/scripts/page-boutique/service-dom.ts | 174 ++++++++++++++++++ .../scripts/page-boutique/service-elements.ts | 26 +++ .../scripts/page-boutique/service-messages.ts | 29 +++ .../src/scripts/scripts-page-boutique.ts | 87 ++------- 8 files changed, 334 insertions(+), 77 deletions(-) create mode 100644 web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/runtime.ts create mode 100644 web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-dom.ts create mode 100644 web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-elements.ts create mode 100644 web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-messages.ts diff --git a/web/app/themes/haiku-atelier-2024/src/scripts-effect/lib/api.ts b/web/app/themes/haiku-atelier-2024/src/scripts-effect/lib/api.ts index dc58274a..a928ba02 100644 --- a/web/app/themes/haiku-atelier-2024/src/scripts-effect/lib/api.ts +++ b/web/app/themes/haiku-atelier-2024/src/scripts-effect/lib/api.ts @@ -1,4 +1,4 @@ -import { Console, Context, Effect, Layer, Match, pipe, Schedule, Schema, SchemaIssue } from "effect"; +import { Console, Context, Effect, Layer, Match, pipe, References, Schedule, Schema, SchemaIssue } from "effect"; import type { SchemaError } from "effect/Schema"; import type { HttpClientError, @@ -11,6 +11,7 @@ import { } from "effect/unstable/http"; import { HttpClientErrorSchema } from "effect/unstable/http/HttpClientError"; import type { CartProduct, GetProducts } from "../schemas/api.ts"; +import { Product } from "../schemas/api.ts"; import { WooCommerceCart } from "../schemas/cart.ts"; /** Le nombre maximal d'essais pour une Requête. */ @@ -52,7 +53,7 @@ const APIFetchClient = FetchHttpClient.layer.pipe( Layer.succeed( FetchHttpClient.RequestInit, { - credentials: "same-origin", + credentials: "include", headers: { Accept: "application/json", "Content-Type": "application/json", @@ -135,18 +136,22 @@ class APIClient extends Context.Service()("haikuatelier.fr/APIClient" ); const GetProducts = Effect.fn("APIClient.GetProducts")( - function*(nonce: string, authString: string, queryParams: GetProducts) { + function*(nonce: string, queryParams: GetProducts) { const request = pipe( - HttpClientRequest.get(`/wp-json/wc/store/products`), + HttpClientRequest.get(`/wp-json/wc/v3/products`), HttpClientRequest.setHeader("Nonce", nonce), - HttpClientRequest.bearerToken(authString), + // TODO: Utiliser l'environnement + HttpClientRequest.basicAuth( + "ck_ed966a2265099a6dfe9915db692cbd2450cceed6", + "cs_a046c91647af95188a3e39a736ebe02f2024e430", + ), // Le corps de la Requête a été validée en amont, on peut utiliser Unsafe. HttpClientRequest.setUrlParams(queryParams), ); const response = yield* pipe( haikuHTTPClient.execute(request), - Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Unknown)), + Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Array(Product))), Effect.mapError(error => matchAPIError(error)), Effect.tapError(error => printErrorAsSuccinctMessage(error)), ); diff --git a/web/app/themes/haiku-atelier-2024/src/scripts-effect/schemas/api.ts b/web/app/themes/haiku-atelier-2024/src/scripts-effect/schemas/api.ts index e3480331..69342d57 100644 --- a/web/app/themes/haiku-atelier-2024/src/scripts-effect/schemas/api.ts +++ b/web/app/themes/haiku-atelier-2024/src/scripts-effect/schemas/api.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { ProductId, ProductQuantity, ProductVariationAttribute } from "./product.ts"; +import { ProductId, ProductQuantity, ProductStatus, ProductVariationAttribute } from "./product.ts"; class CartProduct extends Schema.Class("CartProduct")({ id: ProductId, @@ -7,4 +7,58 @@ class CartProduct extends Schema.Class("CartProduct")({ variation: Schema.Array(ProductVariationAttribute), }) {} -export { CartProduct }; +class GetProducts extends Schema.Class("GetProducts")({ + /** L'ID de la Catégorie de Produits demandé. */ + category: Schema.Int.pipe(Schema.optional), + /** Le numéro de page demandé. */ + page: Schema.Int, + /** Le nombre de Produits par page demandé. */ + per_page: Schema.Int, + /** Le statut demandé des Produits. */ + status: ProductStatus, +}) {} + +class Product extends Schema.Class("Product")({ + attributes: Schema.Unknown, + brands: Schema.Unknown, + // TODO: Pourrait être une énumération. + catalog_visibility: Schema.String, + categories: Schema.Unknown, + description: Schema.String, + dimensions: Schema.Unknown, + featured: Schema.Boolean, + grouped_products: Schema.Unknown, + has_options: Schema.Boolean, + id: Schema.Int, + // NOTE: Non-standard, injecté dans la Réponse. + image_repos: Schema.String, + // NOTE: Non-standard, injecté dans la Réponse. + image_survol: Schema.String, + images: Schema.Unknown, + low_stock_amount: Schema.Union([Schema.Number, Schema.Null]), + menu_order: Schema.Int, + meta_data: Schema.Unknown, + name: Schema.String, + on_sale: Schema.Boolean, + parent_id: Schema.Int, + permalink: Schema.URLFromString, + price: Schema.String, + // NOTE: Non-standard, injecté dans la Réponse. + prix_maximal: Schema.String, + regular_price: Schema.String, + sale_price: Schema.String, + short_description: Schema.String, + sku: Schema.String, + slug: Schema.String, + sold_individually: Schema.Boolean, + stock_quantity: Schema.Union([Schema.Int, Schema.Null]), + // TODO: Pourrait être une énumération. + stock_status: Schema.String, + tags: Schema.Unknown, + type: Schema.Literals(["external", "grouped", "simple", "variable"]), + variations: Schema.Array(Schema.Int), + virtual: Schema.Boolean, + weight: Schema.String, +}) {} + +export { CartProduct, GetProducts, Product }; diff --git a/web/app/themes/haiku-atelier-2024/src/scripts-effect/schemas/product.ts b/web/app/themes/haiku-atelier-2024/src/scripts-effect/schemas/product.ts index 80cb0405..825adeb7 100644 --- a/web/app/themes/haiku-atelier-2024/src/scripts-effect/schemas/product.ts +++ b/web/app/themes/haiku-atelier-2024/src/scripts-effect/schemas/product.ts @@ -2,6 +2,8 @@ import { Effect, Option, pipe, Schema, SchemaIssue, SchemaTransformation } from "effect"; import type { SchemaError } from "effect/Schema"; +const ProductStatus = Schema.Literals(["any", "draft", "future", "pending", "private", "publish", "trash"]); + /** Représente l'identifiant numérique unique d'un Produit. */ const ProductId = Schema.Int.pipe(Schema.brand("ProductId")).check(Schema.isGreaterThan(0)); @@ -78,6 +80,7 @@ export { ProductId, ProductQuantity, ProductQuantityFromString, + ProductStatus, ProductVariation, ProductVariationAttribute, }; diff --git a/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/runtime.ts b/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/runtime.ts new file mode 100644 index 00000000..5ae434cd --- /dev/null +++ b/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/runtime.ts @@ -0,0 +1,17 @@ +import { Console, Layer, ManagedRuntime, pipe } from "effect"; + +import { APIClient } from "../../scripts-effect/lib/api.ts"; +import ShopPageDOM from "./service-dom.ts"; +import ShopPageElements from "./service-elements.ts"; +import ShopPageMessages from "./service-messages.ts"; + +const ShopPageRuntime = ManagedRuntime.make( + pipe( + ShopPageDOM.Live, + Layer.provideMerge(ShopPageMessages.Live), + Layer.provideMerge(ShopPageElements.Live), + Layer.provide(APIClient.Live), + Layer.tapError(error => Console.error("ProductPageRuntime", "Impossible de créer le Layer :", error)), + ), +); +export default ShopPageRuntime; diff --git a/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-dom.ts b/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-dom.ts new file mode 100644 index 00000000..6639ae12 --- /dev/null +++ b/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-dom.ts @@ -0,0 +1,174 @@ +import { + Array as FxArray, + Console, + Context, + Effect, + Layer, + Option, + pipe, + Ref, + Schema, + SchemaIssue, + Stream, + SubscriptionRef, +} from "effect"; +import { SchemaError } from "effect/Schema"; +import html from "html-template-tag"; +import { APIClient } from "../../scripts-effect/lib/api.ts"; +import { setLoadingState } from "../../scripts-effect/lib/elements.ts"; +import { GetProducts, Product } from "../../scripts-effect/schemas/api.ts"; +import { ATTRIBUT_HIDDEN, ATTRIBUT_ID_CATEGORIE_PRODUITS, ATTRIBUT_PAGE } from "../constantes/dom.ts"; +import ShopPageElements from "./service-elements.ts"; +import ShopPageMessages from "./service-messages.ts"; + +/** Le nombre de Produits à afficher par « page ». */ +const PRODUCTS_PER_PAGE = 18; + +/** Forme attendue des données injectées dans la page sous forme de JSON. */ +class PageStates extends Schema.Opaque()( + Schema.Struct({ + authString: Schema.NonEmptyString, + nonce: Schema.NonEmptyString, + }), +) {} + +/** Représente une Erreur liée à un état de page invalide ou incohérent empêchant la poursuite des interactions/de la navigation. */ +class InvalidShopPageStateError + extends Schema.TaggedErrorClass()("InvalidShopPageStateError", { + cause: Schema.String, + }) +{ + /** Créé une `InvalidShopPageStateError` depuis une `SchemaError` levée suite à une validation. */ + static readonly fromSchemaError = (schemaError: SchemaError): InvalidShopPageStateError => + new InvalidShopPageStateError({ + cause: SchemaIssue.makeFormatterDefault()(schemaError.issue), + }); +} + +class ShopPageDOM extends Context.Service()("haikuatelier.fr/Shop/ShopPageDOM", { + make: Effect.gen(function*() { + const { PageStatesRawJson, ProductsGrid, ShowMoreButton } = yield* ShopPageElements; + const { ShowMoreButtonText } = yield* ShopPageMessages; + const API = yield* APIClient; + + const { authString, nonce } = yield* pipe( + PageStatesRawJson.textContent, + (textContent: string) => + Schema.decodeUnknownEffect(Schema.fromJsonString(PageStates))(textContent, { errors: "all" }), + Effect.mapError(InvalidShopPageStateError.fromSchemaError), + ); + + /** ID de la Catégorie des Produits de la Page, si la Page courante est une Archive. */ + const ProductsCategoryId = yield* pipe( + ProductsGrid.getAttribute(ATTRIBUT_ID_CATEGORIE_PRODUITS), + Number, + Option.fromNullishOr, + Ref.make, + ); + + // TODO: Créer une SubscriptionRef mettant à jour le DOM au changement de valeur. + const PageNumber = yield* Ref.make(1); + + /** + * Créé le `DOM` d'une Carte de Produit sous forme de `
`. + */ + const createProductDOM = (product: Product): HTMLElement => { + const article = document.createElement("article"); + article.classList.add("produit"); + article.innerHTML = html`
+ + + $${product.image_repos} + + + + $${product.image_survol} + + + +
+

+ ${product.name} +

+

+ ${product.prix_maximal}€ +

+
+
+ `; + return article; + }; + + /** + * Créé le `DOM` des Cartes d'une nouvelle page de Produits sous forme d'un `DocumentFragment`. + */ + const createNewPageDOM = (products: ReadonlyArray) => { + const fragment: DocumentFragment = document.createDocumentFragment(); + + // Ajoute le HTML des Produits au fragment. + pipe( + FxArray.take(products, PRODUCTS_PER_PAGE), + FxArray.forEach(product => { + const productHTML = createProductDOM(product); + fragment.append(productHTML); + }), + ); + + return fragment; + }; + + const onMoreProductsWantedHandler = Effect.fn("onMoreProductsWantedHandler")(function*() { + yield* Console.debug("onMoreProductsWantedHandler"); + + /** Le numéro de page souhaitée. */ + const newPageNumber = yield* Ref.updateAndGet(PageNumber, pageNumber => pageNumber + 1); + /** L'ID de la Catégorie de Produits affichée dans la page si elle existe. */ + const categoryId = pipe(yield* Ref.get(ProductsCategoryId), Option.getOrUndefined); + + const requestBody = yield* GetProducts.makeEffect({ + page: newPageNumber, + per_page: PRODUCTS_PER_PAGE, + status: "publish", + ...(categoryId && { category: categoryId }), + }); + + // Désactive les interactions et affiche un texte de chargement le temps de la requête. + yield* setLoadingState(ShowMoreButton, true); + yield* SubscriptionRef.set(ShowMoreButtonText, "Getting Products..."); + + const newProducts = yield* API.GetProducts(nonce, requestBody); + yield* Console.debug("onMoreProductsWantedHandler", newProducts); + + // Rétablis le texte du Bouton et réactive les interactions. + yield* SubscriptionRef.set(ShowMoreButtonText, "Show more"); + yield* setLoadingState(ShowMoreButton, false); + + // Cache le bouton s'il y a moins de Produits disponibles que PRODUCTS_PER_PAGE (que l'on est donc à la dernière page). + ShowMoreButton.toggleAttribute(ATTRIBUT_HIDDEN, newProducts.length < PRODUCTS_PER_PAGE); + + // Ajoute les nouveaux Produits dans le DOM. + const newProductsFragment = createNewPageDOM(newProducts); + ProductsGrid.append(newProductsFragment); + ProductsGrid.setAttribute(ATTRIBUT_PAGE, String(newPageNumber)); + }); + + /** + * Initialise l'écouteur d'événements de clic sur le bouton de chargement d'une nouvelle page de Produits (« Show more »). + */ + const initMoreProductsOnButtonClick = Effect.fn("initMoreProductsOnButtonClick")(function*() { + return yield* pipe( + Stream.fromEventListener(ShowMoreButton, "click"), + Stream.tap(onMoreProductsWantedHandler), + Stream.runDrain, + ); + }); + + return { + ProductsCategoryId, + initMoreProductsOnButtonClick, + }; + }), +}) { + static readonly Live = Layer.effect(this, this.make); +} +export default ShopPageDOM; diff --git a/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-elements.ts b/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-elements.ts new file mode 100644 index 00000000..208f4dbf --- /dev/null +++ b/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-elements.ts @@ -0,0 +1,26 @@ +import { Context, Effect, Layer } from "effect"; +import { getFirstSelectorFromDocument } from "../../scripts-effect/lib/dom.ts"; +import { IncoherentDOMError } from "../page-produit/errors.ts"; + +class ShopPageElements extends Context.Service()("haikuatelier.fr/Shop/ShopPageElements", { + make: Effect.gen(function*() { + const PageStatesRawJson = yield* getFirstSelectorFromDocument("#page-states"); + + /** Le Bouton « Show more » pour afficher plus de Produits à la suite de la Grille. */ + const ShowMoreButton = yield* getFirstSelectorFromDocument( + "#page-boutique #bouton-plus-de-produits", + ); + + /** Le conteneur de la Grille des Produits. */ + const ProductsGrid = yield* getFirstSelectorFromDocument("#page-boutique .grille-produits"); + + return { + PageStatesRawJson, + ProductsGrid, + ShowMoreButton, + }; + }).pipe(Effect.mapErrorEager(IncoherentDOMError.fromNoSuchElementError)), +}) { + static readonly Live = Layer.effect(this, this.make); +} +export default ShopPageElements; diff --git a/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-messages.ts b/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-messages.ts new file mode 100644 index 00000000..dcb27d02 --- /dev/null +++ b/web/app/themes/haiku-atelier-2024/src/scripts/page-boutique/service-messages.ts @@ -0,0 +1,29 @@ +import { Context, Effect, Layer, pipe, Stream, SubscriptionRef } from "effect"; + +import ShopPageElements from "./service-elements.ts"; + +class ShopPageMessages extends Context.Service()("haikuatelier.fr/Shop/Messages", { + make: Effect.gen(function*() { + const { ShowMoreButton } = yield* ShopPageElements; + + const ShowMoreButtonText = yield* SubscriptionRef.make("Show more"); + // Const ShowMoreErrorText = yield* SubscriptionRef.make>(Option.none()); + + const initShowMoreButtonUpdates = Effect.fn("initShowMoreButtonUpdates")(function*() { + return yield* pipe( + SubscriptionRef.changes(ShowMoreButtonText), + Stream.tap(newText => { + ShowMoreButton.textContent = newText; + return Effect.succeed(newText); + }), + Stream.runDrain, + ); + }); + + return { ShowMoreButtonText, initShowMoreButtonUpdates }; + }), +}) { + static readonly Live = Layer.effect(this, this.make); +} + +export default ShopPageMessages; diff --git a/web/app/themes/haiku-atelier-2024/src/scripts/scripts-page-boutique.ts b/web/app/themes/haiku-atelier-2024/src/scripts/scripts-page-boutique.ts index 5f8b9d50..9d516e2d 100755 --- a/web/app/themes/haiku-atelier-2024/src/scripts/scripts-page-boutique.ts +++ b/web/app/themes/haiku-atelier-2024/src/scripts/scripts-page-boutique.ts @@ -2,75 +2,24 @@ * Scripts pour les fonctionnalités de la page Boutique. */ -import { pipe } from "@mobily/ts-belt"; -import { tap } from "@mobily/ts-belt/Function"; -import { EitherAsync } from "purify-ts"; -import { match, P } from "ts-pattern"; -import { ValiError } from "valibot"; - -import type { APIFetchErrors } from "./lib/types/api/erreurs"; -import type { WCV3Products, WCV3ProductsArgs } from "./lib/types/api/v3/products.ts"; -import type { GenericPageState } from "./lib/types/pages"; - -import { ROUTE_API_NOUVELLE_PRODUCTS } from "./constantes/api.ts"; -import { PRODUCT_STATUTES } from "./constantes/api/products.ts"; -import { - ATTRIBUT_CHARGEMENT, - ATTRIBUT_DESACTIVE, - ATTRIBUT_HIDDEN, - ATTRIBUT_ID_CATEGORIE_PRODUITS, - ATTRIBUT_PAGE, - DOM_BOUTON_PLUS_PRODUITS, - DOM_GRILLE_PRODUITS, -} from "./constantes/dom.ts"; -import { lanceAnimationCycleLoading } from "./lib/animations.ts"; -import { html, mustGetEleInDocument } from "./lib/dom.ts"; -import { BadRequestError, reporteErreur, ServerError } from "./lib/erreurs.ts"; -import { getBackendAvecParametresUrl, newPartialResponse } from "./lib/reseau.ts"; -import { WCV3ProductsArgsSchema, WCV3ProductsSchema } from "./lib/schemas/api/v3/products.ts"; -import { safeSchemaParse } from "./lib/validation.ts"; - -type APIProductsErrors = - | APIFetchErrors - | ValiError - | ValiError; - -// @ts-expect-error -- États injectés par le modèle PHP -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- États injectés par le modèle PHP -const ETATS_PAGE: GenericPageState = _etats; - -// Numéros magiques -const PRODUCTS_PER_PAGE = 12; - -// Éléments d'intérêt -const E = { - BOUTON_PLUS_DE_PRODUITS: mustGetEleInDocument(DOM_BOUTON_PLUS_PRODUITS), - GRILLE_PRODUITS: mustGetEleInDocument(DOM_GRILLE_PRODUITS), -}; - -/** - * TODO - */ -const initialisePageBoutique = (): void => { - /** ID de la Catégorie de Produits si la Page courante est l'Archive d'une Catégorie. */ - const idCategorieProduits: null | string = E.GRILLE_PRODUITS.getAttribute(ATTRIBUT_ID_CATEGORIE_PRODUITS); - - E.BOUTON_PLUS_DE_PRODUITS.addEventListener("click", (): void => { - /** Le numéro de page demandée par l'Utilisateur. */ - const nouveauNumeroPage = Number(E.GRILLE_PRODUITS.getAttribute(ATTRIBUT_PAGE)) + 1; - /** Les arguments passés à la requête auprès Backend pour la nouvelle page de Produits. */ - const args: WCV3ProductsArgs = { - page: nouveauNumeroPage, - per_page: PRODUCTS_PER_PAGE, - status: PRODUCT_STATUTES.PUBLISH, - // Ajoute conditionnellement la Catégorie de Produits - ...(idCategorieProduits && { category: idCategorieProduits }), - }; - - undefined; - }); -}; +import { Console, Effect } from "effect"; +import ShopPageRuntime from "./page-boutique/runtime.ts"; +import ShopPageDOM from "./page-boutique/service-dom.ts"; +import ShopPageElements from "./page-boutique/service-elements.ts"; +import ShopPageMessages from "./page-boutique/service-messages.ts"; document.addEventListener("DOMContentLoaded", (): void => { - initialisePageBoutique(); + console.debug("scripts-page-boutique"); + // initialisePageBoutique(); + ShopPageRuntime.runFork(Effect.gen(function*() { + const Elements = yield* ShopPageElements; + const DOM = yield* ShopPageDOM; + const Messages = yield* ShopPageMessages; + + yield* Effect.all([DOM.initLoadMoreProductsOnButtonClick(), Messages.initShowMoreButtonUpdates()], { + concurrency: "unbounded", + }).pipe(Effect.tapCause(Console.error)); + + console.debug(Elements.ProductsGrid); + })); });