ref(boutique) wip scripts sous forme Effect

This commit is contained in:
gcch 2026-04-29 10:45:49 +02:00
commit 305fcce1ba
7 changed files with 248 additions and 71 deletions

View file

@ -1,5 +1,5 @@
import { Schema } from "effect"; 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>("CartProduct")({ class CartProduct extends Schema.Class<CartProduct>("CartProduct")({
id: ProductId, id: ProductId,
@ -7,4 +7,15 @@ class CartProduct extends Schema.Class<CartProduct>("CartProduct")({
variation: Schema.Array(ProductVariationAttribute), variation: Schema.Array(ProductVariationAttribute),
}) {} }) {}
export { CartProduct }; class GetProducts extends Schema.Class<GetProducts>("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,
}) {}
export { CartProduct, GetProducts };

View file

@ -2,6 +2,8 @@
import { Effect, Option, pipe, Schema, SchemaIssue, SchemaTransformation } from "effect"; import { Effect, Option, pipe, Schema, SchemaIssue, SchemaTransformation } from "effect";
import type { SchemaError } from "effect/Schema"; 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. */ /** Représente l'identifiant numérique unique d'un Produit. */
const ProductId = Schema.Int.pipe(Schema.brand("ProductId")).check(Schema.isGreaterThan(0)); const ProductId = Schema.Int.pipe(Schema.brand("ProductId")).check(Schema.isGreaterThan(0));
@ -78,6 +80,7 @@ export {
ProductId, ProductId,
ProductQuantity, ProductQuantity,
ProductQuantityFromString, ProductQuantityFromString,
ProductStatus,
ProductVariation, ProductVariation,
ProductVariationAttribute, ProductVariationAttribute,
}; };

View file

@ -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;

View file

@ -0,0 +1,142 @@
import {
Console,
Context,
Effect,
Layer,
Option,
pipe,
Ref,
Schema,
SchemaIssue,
Stream,
SubscriptionRef,
} from "effect";
import { SchemaError } from "effect/Schema";
import { APIClient } from "../../scripts-effect/lib/api.ts";
import { setLoadingState } from "../../scripts-effect/lib/elements.ts";
import { GetProducts } from "../../scripts-effect/schemas/api.ts";
import { ATTRIBUT_ID_CATEGORIE_PRODUITS } from "../constantes/dom.ts";
import ShopPageElements from "./service-elements.ts";
import ShopPageMessages from "./service-messages.ts";
const PRODUCTS_PER_PAGE = 18;
const PageStatesSchema = Schema.Struct({
authString: Schema.NonEmptyString,
nonce: Schema.NonEmptyString,
});
class InvalidPageStateError extends Schema.TaggedErrorClass<InvalidPageStateError>()("InvalidPageStateError", {
cause: Schema.String,
}) {
static readonly fromSchemaError = (schemaError: SchemaError): InvalidPageStateError =>
new InvalidPageStateError({
cause: SchemaIssue.makeFormatterDefault()(schemaError.issue),
});
}
class ShopPageDOM extends Context.Service<ShopPageDOM>()("haikuatelier.fr/Shop/ShopPageDOM", {
make: Effect.gen(function*() {
const Elements = yield* ShopPageElements;
const Messages = yield* ShopPageMessages;
const API = yield* APIClient;
const PageStates = yield* pipe(
Elements.PageStatesRawJson.textContent,
(textContent: string) =>
Schema.decodeUnknownEffect(Schema.fromJsonString(PageStatesSchema))(textContent, { errors: "all" }),
Effect.mapError(InvalidPageStateError.fromSchemaError),
);
/** ID de la Catégorie des Produits de la Page, si la Page courante est une Archive. */
const ProductsCategoryId: Ref.Ref<Option.Option<number>> = yield* Ref.make(
Option.fromNullishOr(Number(Elements.ProductsGrid.getAttribute(ATTRIBUT_ID_CATEGORIE_PRODUITS))),
);
// TODO: Créer une SubscriptionRef mettant à jour le DOM au changement de valeur.
const PageNumber = yield* Ref.make(1);
const onMoreProductedWantedHandler = Effect.fn("onMoreProductedWantedHandler")(function*() {
yield* Console.debug("onMoreProductedWantedHandler");
const newPageNumber = yield* Ref.getAndUpdate(PageNumber, pageNumber => pageNumber + 1);
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(Elements.ShowMoreButton, true);
yield* SubscriptionRef.set(Messages.ShowMoreButtonText, "Getting Products...");
const newProducts = yield* API.GetProducts(PageStates.nonce, PageStates.authString, requestBody);
yield* Console.debug("onMoreProductedWantedHandler", newProducts);
// Rétablis le texte du Bouton et réactive les interactions.
yield* SubscriptionRef.set(Messages.ShowMoreButtonText, "Show more");
yield* setLoadingState(Elements.ShowMoreButton, false);
// Cache le bouton s'il y a moins de PRODUCTS_PER_PAGE Produits disponibles (que l'on est à la dernière page)
if (donnees.length < PRODUCTS_PER_PAGE) {
E.BOUTON_PLUS_DE_PRODUITS.toggleAttribute(ATTRIBUT_HIDDEN);
}
// Créé un DocumentFragment qui recevra tous les nouveaux Produits
const fragment: DocumentFragment = document.createDocumentFragment();
// Créé les Éléments <article> à insérer
for (const produit of donnees.slice(0, PRODUCTS_PER_PAGE)) {
pipe(
html`
<article class="produit">
<figure>
<a href="/product/${produit.slug}">
<picture class="produit__illustration produit__illustration__principale">
${produit.image_repos ?? ""}
</picture>
<picture class="produit__illustration produit__illustration__survol">
${produit.image_survol ?? ""}
</picture>
</a>
<figcaption class="produit__textuel">
<h3 class="produit__textuel__titre">
<a href="${produit.permalink}">${produit.name}</a>
</h3>
<p class="produit__textuel__prix">
${produit.prix_maximal}
</p>
</figcaption>
</figure>
</article>
`,
tap(article => fragment.append(article)),
);
}
// Ajoute les nouveaux Produits dans le DOM
E.GRILLE_PRODUITS.append(fragment);
E.GRILLE_PRODUITS.setAttribute(ATTRIBUT_PAGE, String(nouveauNumeroPage));
});
const initLoadMoreProductsOnButtonClick = Effect.fn("initLoadMoreProductsOnButtonClick")(function*() {
return yield* pipe(
Stream.fromEventListener(Elements.ShowMoreButton, "click"),
Stream.tap(onMoreProductedWantedHandler),
Stream.runDrain,
);
});
return {
ProductsCategoryId,
initLoadMoreProductsOnButtonClick,
};
}),
}) {
static readonly Live = Layer.effect(this, this.make);
}
export default ShopPageDOM;

View file

@ -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<ShopPageElements>()("haikuatelier.fr/Shop/ShopPageElements", {
make: Effect.gen(function*() {
const PageStatesRawJson = yield* getFirstSelectorFromDocument<HTMLScriptElement>("#page-states");
/** Le Bouton « Show more » pour afficher plus de Produits à la suite de la Grille. */
const ShowMoreButton = yield* getFirstSelectorFromDocument<HTMLButtonElement>(
"#page-boutique #bouton-plus-de-produits",
);
/** Le conteneur de la Grille des Produits. */
const ProductsGrid = yield* getFirstSelectorFromDocument<HTMLDivElement>("#page-boutique .grille-produits");
return {
PageStatesRawJson,
ProductsGrid,
ShowMoreButton,
};
}).pipe(Effect.mapErrorEager(IncoherentDOMError.fromNoSuchElementError)),
}) {
static readonly Live = Layer.effect(this, this.make);
}
export default ShopPageElements;

View file

@ -0,0 +1,29 @@
import { Context, Effect, Layer, pipe, Stream, SubscriptionRef } from "effect";
import ShopPageElements from "./service-elements.ts";
class ShopPageMessages extends Context.Service<ShopPageMessages>()("haikuatelier.fr/Shop/Messages", {
make: Effect.gen(function*() {
const { ShowMoreButton } = yield* ShopPageElements;
const ShowMoreButtonText = yield* SubscriptionRef.make("Add to cart");
// Const ShowMoreErrorText = yield* SubscriptionRef.make<Option.Option<string>>(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;

View file

@ -2,75 +2,24 @@
* Scripts pour les fonctionnalités de la page Boutique. * Scripts pour les fonctionnalités de la page Boutique.
*/ */
import { pipe } from "@mobily/ts-belt"; import { Effect } from "effect";
import { tap } from "@mobily/ts-belt/Function"; import ShopPageRuntime from "./page-boutique/runtime.ts";
import { EitherAsync } from "purify-ts"; import ShopPageDOM from "./page-boutique/service-dom.ts";
import { match, P } from "ts-pattern"; import ShopPageElements from "./page-boutique/service-elements.ts";
import { ValiError } from "valibot"; import ShopPageMessages from "./page-boutique/service-messages.ts";
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<typeof WCV3ProductsArgsSchema>
| ValiError<typeof WCV3ProductsSchema>;
// @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<HTMLButtonElement>(DOM_BOUTON_PLUS_PRODUITS),
GRILLE_PRODUITS: mustGetEleInDocument<HTMLDivElement>(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;
});
};
document.addEventListener("DOMContentLoaded", (): void => { 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",
});
console.debug(Elements.ProductsGrid);
}));
}); });