2026-04-01

This commit is contained in:
gcch 2026-04-01 17:07:06 +02:00
commit 5f332f4068
34 changed files with 9392 additions and 391 deletions

View file

@ -8,15 +8,5 @@
"!vtsls", "!vtsls",
"..." "..."
], ],
"languages": { "languages": {}
"PHP": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "mago",
"arguments": ["format", "--stdin-input"]
}
}
}
}
} }

1673
bun.lock Normal file

File diff suppressed because it is too large Load diff

3
cfg/oxlint.config.ts Normal file
View file

@ -0,0 +1,3 @@
import config from "@gcch/configuration-oxlint";
export default config;

View file

@ -1,37 +1,25 @@
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true, fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
// Définis un délai d'exécution.
timeout: 30000,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "list", reporter: "list",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ retries: 1,
testDir: "../tests",
timeout: 10_000,
workers: "100%",
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "https://haikuatelier.gcch.local", baseURL: "https://haikuatelier.gcch.local",
trace: "retry-with-trace",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
clientCertificates: [ clientCertificates: [
{ {
origin: "https://haikuatelier.gcch.local", origin: "https://haikuatelier.gcch.local",
certPath: "./containers/data/certs/_wildcard.gcch.local.pem", certPath: "../containers/data/certs/_wildcard.gcch.local.pem",
keyPath: "./containers/data/certs/_wildcard.gcch.local-key.pem", keyPath: "../containers/data/certs/_wildcard.gcch.local-key.pem",
}, },
], ],
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
}, },
/* Configure projects for major browsers */
projects: [ projects: [
{ {
name: "desktop-chromium-1920", name: "desktop-chromium-1920",
@ -74,10 +62,4 @@ export default defineConfig({
// use: { ...devices["Pixel 7 landscape"] }, // use: { ...devices["Pixel 7 landscape"] },
// }, // },
], ],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
}); });

View file

@ -89,7 +89,6 @@
"php-standard-library/phpstan-extension": "^2.1", "php-standard-library/phpstan-extension": "^2.1",
"phpstan/extension-installer": "^1.4.3", "phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.45", "phpstan/phpstan": "^2.1.45",
"rector/rector": "^2.3.9",
"roave/security-advisories": "dev-latest", "roave/security-advisories": "dev-latest",
"szepeviktor/phpstan-wordpress": "2.x-dev", "szepeviktor/phpstan-wordpress": "2.x-dev",
"vincentlanglet/twig-cs-fixer": "^3.14" "vincentlanglet/twig-cs-fixer": "^3.14"

7381
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -101,7 +101,9 @@ watch-js:
[group('qualité')] [group('qualité')]
lint-js: lint-js:
-bun eslint "web/app/themes/haiku-atelier-2024/src/scripts" -bun eslint "web/app/themes/haiku-atelier-2024/src/scripts"
-bun oxlint "web/app/themes/haiku-atelier-2024/src/scripts" bun --bun oxlint \
--config cfg/oxlint.config.ts \
--format stylish
# Vérifie le code Sass avec Stylelint. # Vérifie le code Sass avec Stylelint.
[group('css')] [group('css')]

View file

@ -7,16 +7,15 @@
"license": "ISC", "license": "ISC",
"main": "index.js", "main": "index.js",
"keywords": [], "keywords": [],
"scripts": { "knip": "knip" }, "scripts": {
"knip": "knip"
},
"dependencies": { "dependencies": {
"@effect/language-service": "^0.60.0",
"@mobily/ts-belt": "v4.0.0-rc.5", "@mobily/ts-belt": "v4.0.0-rc.5",
"@sentry/browser": "^10.47.0", "@sentry/browser": "^10.47.0",
"a11y-dialog": "^8.1.4", "a11y-dialog": "^8.1.4",
"chalk": "^5.6.2",
"effect": "^3.21.0", "effect": "^3.21.0",
"lit-html": "^3.3.1", "lit-html": "^3.3.1",
"optics-ts": "^2.4.1",
"purify-ts": "2.1.2", "purify-ts": "2.1.2",
"ts-pattern": "^5.9.0", "ts-pattern": "^5.9.0",
"valibot": "1.1.0" "valibot": "1.1.0"
@ -28,7 +27,6 @@
"@gcch/configuration-prettier": "git+https://git.gcch.fr/gcch/configuration-prettier#8de937e801", "@gcch/configuration-prettier": "git+https://git.gcch.fr/gcch/configuration-prettier#8de937e801",
"@playwright/test": "^1.59.0", "@playwright/test": "^1.59.0",
"@sentry/core": "^10.47.0", "@sentry/core": "^10.47.0",
"@swc/cli": "0.7.8",
"@types/bun": "^1.3.11", "@types/bun": "^1.3.11",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitejs/plugin-legacy": "^8.0.1", "@vitejs/plugin-legacy": "^8.0.1",
@ -49,8 +47,9 @@
"lightningcss-cli": "^1.32.0", "lightningcss-cli": "^1.32.0",
"oxlint": "^1.58.0", "oxlint": "^1.58.0",
"oxlint-tsgolint": "^0.19.0", "oxlint-tsgolint": "^0.19.0",
"playwright": "^1.59.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-pkg": "^0.21.2", "prettier-plugin-pkg": "^0.22.1",
"prettier-plugin-sh": "^0.18.0", "prettier-plugin-sh": "^0.18.0",
"sass-embedded": "^1.98.0", "sass-embedded": "^1.98.0",
"stylelint": "^17.6.0", "stylelint": "^17.6.0",

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__ . '/web/app/themes/haiku-atelier-2024'])
->withSkip([__DIR__ . '/vendor', __DIR__ . '/node_modules'])
->withPhpSets(php85: true)
->withCodeQualityLevel(10)
->withCodingStyleLevel(10)
->withDeadCodeLevel(10)
->withTypeCoverageDocblockLevel(10)
->withTypeCoverageLevel(10)
->withImportNames(
importDocBlockNames: true,
importNames: true,
importShortClasses: true,
removeUnusedImports: true
)
->withPreparedSets(
carbon: true,
instanceOf: true,
privatization: true
);

View file

@ -1,7 +0,0 @@
#!/usr/bin/fish
for image in *.png
gm convert -resize 1000 $image ok-$image.png
end
flaca -p *

View file

@ -1,2 +1,4 @@
#!/usr/bin/fish
ssh ade -- fish /srv/haikuatelier.com/scripts/sauvegarde-bdd-production.fish ssh ade -- fish /srv/haikuatelier.com/scripts/sauvegarde-bdd-production.fish
rclone copy --check-first --progress --multi-thread-streams 8 ade:/srv/haikuatelier.com/db /home/gcch/Répertoires/git.gcch.fr/gcch/haiku-atelier-2024/db rclone copy --check-first --progress --multi-thread-streams 8 ade:/srv/haikuatelier.com/db /home/gcch/Répertoires/git.gcch.fr/gcch/haiku-atelier-2024/db

View file

@ -1,3 +1,5 @@
#!/usr/bin/fish
set -f fichiers_toml (fd --glob "*.toml") set -f fichiers_toml (fd --glob "*.toml")
set -f fichiers_angie (fd --glob "*.conf" containers/conf/angie) set -f fichiers_angie (fd --glob "*.conf" containers/conf/angie)

View file

@ -0,0 +1,40 @@
import { $ } from "bun";
import { Array, Option, Order, pipe } from "effect";
import { readdir } from "node:fs/promises";
const launchContainers = async (): Promise<string> => {
return await $`podman compose up -d`.text();
};
const getLatestDbExport = async (): Promise<string> => {
return pipe(
await readdir(`../db`),
(paths: ReadonlyArray<string>) => Array.sort(paths, Order.string),
(sortedPaths: ReadonlyArray<string>) => Array.last(sortedPaths),
(last: Option.Option<string>) =>
Option.getOrThrowWith(last, () => new Error("Aucun export de BDD n'est disponible.")),
);
};
const importLatestDbInWordpressContainer = async (exportPath: string) => {
await $`podman exec -it haikuatelier.fr-wordpress fish -c "cd web && wp --allow-root db import ${exportPath}"`;
};
try {
// S'assure que les conteneurs soient lancées.
await launchContainers();
const latestExportPath: string = `../db/${await getLatestDbExport()}`;
console.log(`Dernier export : ${latestExportPath}`);
// Exécute l'opération d'import dans le conteneur WordPress via wp-cli.
await importLatestDbInWordpressContainer(latestExportPath);
} catch (error: unknown) {
if (error instanceof $.ShellError) {
console.error(`Commande échouée avec code d'erreur: ${error.exitCode}`);
console.log(error.stdout.toString());
console.log(error.stderr.toString());
} else {
console.error(error);
}
}

View file

@ -1,3 +1,5 @@
#!/usr/bin/fish
cd /srv/haikuatelier.com/web cd /srv/haikuatelier.com/web
sudo -S wp-cli --allow-root db export sudo -S wp-cli --allow-root db export
sudo -S mv -v /srv/haikuatelier.com/web/*.sql ../db sudo -S mv -v /srv/haikuatelier.com/web/*.sql ../db

View file

@ -1,3 +1,5 @@
#!/usr/bin/fish
pyftsubset \ pyftsubset \
lato-variable-italic.ttf \ lato-variable-italic.ttf \
--desubroutinize \ --desubroutinize \

View file

@ -8,7 +8,7 @@ test("can scroll to the end of the grid", async ({ page }): Promise<void> => {
await scrollToGridsEnd(page); await scrollToGridsEnd(page);
}); });
test("can access all Products' pages", async ({ page, request }): Promise<void> => { test.skip("can access all Products' pages", async ({ page, request }): Promise<void> => {
await page.goto("https://haikuatelier.gcch.local/shop/"); await page.goto("https://haikuatelier.gcch.local/shop/");
const links = await getAllProductsLinks(page, request); const links = await getAllProductsLinks(page, request);
@ -46,7 +46,7 @@ const scrollToGridsEnd = async (page: Page): Promise<void> => {
await expect(showMoreButton, "The 'Show more' button is visible").toBeVisible(); await expect(showMoreButton, "The 'Show more' button is visible").toBeVisible();
while (hasMoreProducts) { while (hasMoreProducts) {
const newProductsResponse: Promise<Response> = page.waitForResponse(new RegExp(".*wp-json\/wc\/v3\/products.*")); const newProductsResponse: Promise<Response> = page.waitForResponse(new RegExp(".*wp-json/wc/v3/products.*"));
await showMoreButton.click(); await showMoreButton.click();
await newProductsResponse; await newProductsResponse;

View file

@ -321,6 +321,7 @@ button.bouton-retour-haut {
background: var(--couleur-fond); background: var(--couleur-fond);
box-shadow: initial; box-shadow: initial;
transition: 0.2s background, 0.2s opacity, 0.2s visibility; transition: 0.2s background, 0.2s opacity, 0.2s visibility;
z-index: 500;
} }
button.bouton-retour-haut img { button.bouton-retour-haut img {
width: 1rem; width: 1rem;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -16,28 +16,28 @@ require_once __DIR__ . '/src/inc/TraitementInformations.php';
$context = Timber::context(); $context = Timber::context();
$templates = ['produit.twig']; $templates = ['produit.twig'];
$product = wc_get_product(); $raw_product = wc_get_product();
// Le Produit DOIT exister. // Le Produit DOIT exister.
if ($product === null || is_bool($product)) { if ($raw_product === null || is_bool($raw_product)) {
throw new Exception("Le Produit n'existe pas."); throw new Exception("Le Produit n'existe pas.");
} }
// Assemble les données d'intérêt pour la page au sein d'une Classe. // Assemble les données d'intérêt pour la page au sein d'une Classe.
$donnees_produit = Product::new($product); $product = Product::new($raw_product);
/** @var int $prix_maximal Le prix de la Variation la plus chère */ /** @var int $prix_maximal Le prix de la Variation la plus chère */
$prix_maximal = collect($donnees_produit->variations)->max('price'); $maximum_price = collect($product->variations)->max('price');
$produits_meme_collection = array_map( $same_collection_products = array_map(
array: recupere_produits_meme_collection($donnees_produit->collection)($donnees_produit->id), array: recupere_produits_meme_collection($product->collection)($product->id),
callback: Product::new(...) callback: Product::new(...)
); );
$context['produit'] = $donnees_produit; $context['product'] = $product;
$context['product_json'] = wp_json_encode($donnees_produit); $context['product_json'] = wp_json_encode($product);
$context['prix_maximal'] = $prix_maximal; $context['maximum_price'] = $maximum_price;
$context['produits_meme_collection'] = $produits_meme_collection; $context['same_collection_products'] = $same_collection_products;
/** /**
* Charge les Scripts nécessaires pour la page Produit. * Charge les Scripts nécessaires pour la page Produit.
@ -59,9 +59,6 @@ function charge_scripts_page_produit(): void {
add_action('wp_enqueue_scripts', 'charge_scripts_page_produit'); add_action('wp_enqueue_scripts', 'charge_scripts_page_produit');
$lal = wp_json_encode($context);
echo "<script>console.debug({$lal});</script>";
// Rendu // Rendu
Timber::render( Timber::render(
filenames: $templates, filenames: $templates,

View file

@ -21,11 +21,10 @@ use function Crell\fp\pipe;
* *
* @return string TODO * @return string TODO
*/ */
function genere_balise_img_multiformats($id, bool $lazy = false): string function genere_balise_img_multiformats(string $id, bool $lazy = false): string {
{
$int_id = (int) $id; $int_id = (int) $id;
if (-1 === $id) { if (-1 === $int_id) {
return ''; return '';
} }
@ -43,9 +42,11 @@ function genere_balise_img_multiformats($id, bool $lazy = false): string
[$avif, $jxl, $webp], [$avif, $jxl, $webp],
static fn($tableau): array => array_filter( static fn($tableau): array => array_filter(
array: $tableau, array: $tableau,
callback: static fn($chemin_format): bool => false !== $chemin_format, callback: static fn($chemin_format): bool => false !== $chemin_format
), ),
static fn($tableau): array => array_map(array: $tableau, callback: static fn($chemin_format): array => [ static fn($tableau): array => array_map(
array: $tableau,
callback: static fn($chemin_format): array => [
'format' => pathinfo((string) $chemin_format)['extension'], 'format' => pathinfo((string) $chemin_format)['extension'],
'taille' => filesize($chemin_format), 'taille' => filesize($chemin_format),
'url' => 'url' =>
@ -53,10 +54,14 @@ function genere_balise_img_multiformats($id, bool $lazy = false): string
. '/' . '/'
. pathinfo($url)['filename'] . pathinfo($url)['filename']
. '.' . '.'
. pathinfo((string) $chemin_format)['extension'], . pathinfo((string) $chemin_format)['extension']
]), ]
)
);
usort(
array: $formats,
callback: static fn($a, $b): int => $a['taille'] <=> $b['taille']
); );
usort(array: $formats, callback: static fn($a, $b): int => $a['taille'] <=> $b['taille']);
// Construis les balises <source> avec les formats valides // Construis les balises <source> avec les formats valides
$sources = ''; $sources = '';
@ -86,8 +91,7 @@ function genere_balise_img_multiformats($id, bool $lazy = false): string
/** /**
* TODO. * TODO.
*/ */
function tri_variations_par_prix_descendant(WC_Product $a, WC_Product $b): int function tri_variations_par_prix_descendant(WC_Product $a, WC_Product $b): int {
{
return $b->get_price() <=> $a->get_price(); return $b->get_price() <=> $a->get_price();
} }
@ -97,23 +101,25 @@ function tri_variations_par_prix_descendant(WC_Product $a, WC_Product $b): int
* *
* @return mixed un tableau avec uniquement les informations pour la Grille de Produits * @return mixed un tableau avec uniquement les informations pour la Grille de Produits
*/ */
function recupere_informations_produit_shop(WC_Product $produit): mixed function recupere_informations_produit_shop(WC_Product $produit): mixed {
{
/** @var int $prix_maximal Le prix maximal du Produit. */ /** @var int $prix_maximal Le prix maximal du Produit. */
$prix_maximal = pipe( $prix_maximal = pipe(
// Récupère les Variations // Récupère les Variations
$produit->get_children(), $produit->get_children(),
// Récupère les informations de chaque Variation // Récupère les informations de chaque Variation
static fn($enfants): array => array_map(callback: wc_get_product(...), array: $enfants), static fn($enfants): array => array_map(
callback: wc_get_product(...),
array: $enfants
),
// Trie les Variations par prix descendant // Trie les Variations par prix descendant
static fn($variations): array => array_map( static fn($variations): array => array_map(
callback: static fn($variation) => $variation->get_price(), callback: static fn($variation) => $variation->get_price(),
array: $variations, array: $variations
), ),
// Récupère le Prix de la Variation la plus chère // Récupère le Prix de la Variation la plus chère
static fn($prix) => collect($prix)->max(), static fn($prix) => collect($prix)->max(),
// Récupère le Prix pour la Variation la plus chère OU le prix du Produit simple // Récupère le Prix pour la Variation la plus chère OU le prix du Produit simple
static fn($prix_variation_maximale) => $prix_variation_maximale ?? $produit->get_price(), static fn($prix_variation_maximale) => $prix_variation_maximale ?? $produit->get_price()
); );
// TEMP: Cas de la Carte Cadeau où aucun prix ne doit être affiché. Idéalement utiliser un système d'étiquettes pour ces cas là. // TEMP: Cas de la Carte Cadeau où aucun prix ne doit être affiché. Idéalement utiliser un système d'étiquettes pour ces cas là.
@ -131,15 +137,15 @@ function recupere_informations_produit_shop(WC_Product $produit): mixed
// Photo du Produit affichée par défaut // Photo du Produit affichée par défaut
'photo_repos' => genere_balise_img_multiformats( 'photo_repos' => genere_balise_img_multiformats(
get_post_meta($post_id = $produit->get_id(), $key = '_photos_colonne_gauche|||0|value')[0] ?? -1, get_post_meta($post_id = $produit->get_id(), $key = '_photos_colonne_gauche|||0|value')[0] ?? -1,
false, false
), ),
// Photo du Produit affichée au survol de l'image // Photo du Produit affichée au survol de l'image
'photo_survol' => genere_balise_img_multiformats( 'photo_survol' => genere_balise_img_multiformats(
get_post_meta($post_id = $produit->get_id(), $key = '_photos_colonne_droite|||0|value')[0] ?? -1, get_post_meta($post_id = $produit->get_id(), $key = '_photos_colonne_droite|||0|value')[0] ?? -1,
true, true
), ),
// URL du Produit pour les liens vers celui-ci // URL du Produit pour les liens vers celui-ci
'url' => $produit->get_permalink(), 'url' => $produit->get_permalink()
]; ];
} }
@ -148,8 +154,7 @@ function recupere_informations_produit_shop(WC_Product $produit): mixed
/** /**
* Retourne un tableau associatif des informations affichées sur la page Produit depuis les données brutes d'un Produit. * Retourne un tableau associatif des informations affichées sur la page Produit depuis les données brutes d'un Produit.
*/ */
function recupere_informations_produit_page_produit(WC_Product $product): mixed function recupere_informations_produit_page_produit(WC_Product $product): mixed {
{
/** @var list<Attribute> */ /** @var list<Attribute> */
$attributs = Product::get_attributes_for_product($product); $attributs = Product::get_attributes_for_product($product);
@ -170,19 +175,19 @@ function recupere_informations_produit_page_produit(WC_Product $product): mixed
'prix' => $product->get_price(), 'prix' => $product->get_price(),
'photos_colonne_gauche' => array_map( 'photos_colonne_gauche' => array_map(
callback: genere_balise_img_multiformats(...), callback: genere_balise_img_multiformats(...),
array: get_post_meta($post_id = $product->get_id(), $key = '_photos_colonne_gauche|||0|value'), array: get_post_meta($post_id = $product->get_id(), $key = '_photos_colonne_gauche|||0|value')
), ),
'photos_colonne_droite' => array_map( 'photos_colonne_droite' => array_map(
callback: genere_balise_img_multiformats(...), callback: genere_balise_img_multiformats(...),
array: carbon_get_the_post_meta('photos_colonne_droite'), array: carbon_get_the_post_meta('photos_colonne_droite')
), ),
'photo_repos' => genere_balise_img_multiformats( 'photo_repos' => genere_balise_img_multiformats(
get_post_meta($post_id = $product->get_id(), $key = '_photos_colonne_gauche|||0|value')[0] ?? -1, get_post_meta($post_id = $product->get_id(), $key = '_photos_colonne_gauche|||0|value')[0] ?? -1,
false, false
), ),
'photo_survol' => genere_balise_img_multiformats( 'photo_survol' => genere_balise_img_multiformats(
get_post_meta($post_id = $product->get_id(), $key = '_photos_colonne_droite|||0|value')[0] ?? -1, get_post_meta($post_id = $product->get_id(), $key = '_photos_colonne_droite|||0|value')[0] ?? -1,
true, true
), ),
// Slug du Produit // Slug du Produit
'slug' => $product->get_slug(), 'slug' => $product->get_slug(),
@ -191,7 +196,7 @@ function recupere_informations_produit_page_produit(WC_Product $product): mixed
// Variations du Produit // Variations du Produit
'variations_ids' => $product->get_children(), 'variations_ids' => $product->get_children(),
// URL du Produit // URL du Produit
'url' => $product->get_permalink(), 'url' => $product->get_permalink()
]; ];
} }
@ -201,8 +206,7 @@ function recupere_informations_produit_page_produit(WC_Product $product): mixed
* *
* Pour faciliter l'usage avec `array_map`, utilise une fonction avec curryfication. * Pour faciliter l'usage avec `array_map`, utilise une fonction avec curryfication.
*/ */
function recupere_produits_meme_collection(string $slug_collection): mixed function recupere_produits_meme_collection(string $slug_collection): mixed {
{
// @param int $id_produit // @param int $id_produit
return static fn($id_produit) => wc_get_products([ return static fn($id_produit) => wc_get_products([
'exclude' => [$id_produit], 'exclude' => [$id_produit],
@ -210,17 +214,16 @@ function recupere_produits_meme_collection(string $slug_collection): mixed
'order' => 'DESC', 'order' => 'DESC',
'orderby' => 'date', 'orderby' => 'date',
'status' => 'publish', 'status' => 'publish',
'tax_query' => [['taxonomy' => 'collection', 'field' => 'slug', 'terms' => $slug_collection]], 'tax_query' => [['taxonomy' => 'collection', 'field' => 'slug', 'terms' => $slug_collection]]
]); ]);
} }
// Page Panier // Page Panier
function recupere_et_formate_attributs_produit(mixed $attributs_produit): mixed function recupere_et_formate_attributs_produit(mixed $attributs_produit): mixed {
{
return [ return [
'taille' => ['nom' => 'Size', 'valeur' => $attributs_produit['pa_size'] ?? false], 'taille' => ['nom' => 'Size', 'valeur' => $attributs_produit['pa_size'] ?? false],
'pierre' => ['nom' => 'Stone', 'valeur' => $attributs_produit['pa_stone'] ?? false], 'pierre' => ['nom' => 'Stone', 'valeur' => $attributs_produit['pa_stone'] ?? false],
'cote' => ['nom' => 'Side', 'valeur' => $attributs_produit['pa_side'] ?? false], 'cote' => ['nom' => 'Side', 'valeur' => $attributs_produit['pa_side'] ?? false]
]; ];
} }

View file

@ -80,6 +80,7 @@ button {
background: var(--couleur-fond); background: var(--couleur-fond);
box-shadow: initial; box-shadow: initial;
transition: 0.2s background, 0.2s opacity, 0.2s visibility; transition: 0.2s background, 0.2s opacity, 0.2s visibility;
z-index: 500;
img { img {
width: 1rem; width: 1rem;

View file

@ -7,8 +7,9 @@ import { getOptionOrThrowWithError } from "./utils";
export type ParentElement = Document | Element; export type ParentElement = Document | Element;
export const getFirstSelectorFromParent = export const getFirstSelectorFromParent =
(parent: ParentElement) => <E extends Element = Element>(selector: string): Option.Option<NonNullable<E>> => (parent: ParentElement) =>
Option.fromNullishOr(parent.querySelector<E>(selector)); <E extends Element = Element>(selector: string): Option.Option<NonNullable<E>> =>
Option.fromNullable(parent.querySelector<E>(selector));
export const getFirstSelectorFromDocument = <E extends Element = Element>( export const getFirstSelectorFromDocument = <E extends Element = Element>(
selector: string, selector: string,
@ -21,12 +22,13 @@ export const getFirstSelectorFromDocumentOrThrow = <E extends Element = Element>
); );
export const getAllSelectorFromParent = export const getAllSelectorFromParent =
(parent: ParentElement) => <E extends Element = Element>(selector: string): Option.Option<NonEmptyReadonlyArray<E>> => (parent: ParentElement) =>
<E extends Element = Element>(selector: string): Option.Option<NonEmptyReadonlyArray<E>> =>
pipe( pipe(
parent.querySelectorAll<E>(selector), parent.querySelectorAll<E>(selector),
// Convertis NodeListOf en Array. // Convertis NodeListOf en Array.
Array.from<E>, Array.from<E>,
(xs: Array<E>) => Option.liftPredicate(EffectArray.isReadonlyArrayNonEmpty)(xs), (xs: Array<E>) => Option.liftPredicate(EffectArray.isNonEmptyReadonlyArray)(xs),
); );
export const getAllSelectorFromDocument = <E extends Element = Element>( export const getAllSelectorFromDocument = <E extends Element = Element>(

View file

@ -1,6 +1,8 @@
import { pipe, Option } from "effect"; import { pipe, Option } from "effect";
export const getOptionOrThrowWithError = (message: string) => <T>(option: Option.Option<T>): T => export const getOptionOrThrowWithError =
(message: string) =>
<T>(option: Option.Option<T>): T =>
pipe( pipe(
option, option,
Option.getOrThrowWith(() => new Error(message)), Option.getOrThrowWith(() => new Error(message)),

View file

@ -4,13 +4,7 @@ import { match } from "ts-pattern";
import type { HttpCodeErrors, SimplifiedResponse } from "./types/reseau"; import type { HttpCodeErrors, SimplifiedResponse } from "./types/reseau";
import { ENTETE_WC_NONCE } from "../constantes/api.ts"; import { ENTETE_WC_NONCE } from "../constantes/api.ts";
import { import { BadRequestError, ForbiddenError, NotFoundError, ServerError, UnauthorizedError } from "./erreurs.ts";
BadRequestError,
ForbiddenError,
NotFoundError,
ServerError,
UnauthorizedError,
} from "./erreurs.ts";
// Types // Types
@ -59,9 +53,7 @@ export const getBackend = (args: ArgumentsGetBackendWC): Promise<Response> =>
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
export const getBackendAvecParametresUrl = ( export const getBackendAvecParametresUrl = (args: ArgumentsGetBackendWC): Promise<Response> =>
args: ArgumentsGetBackendWC,
): Promise<Response> =>
fetch(`${args.route}?${args.searchParams}`, { fetch(`${args.route}?${args.searchParams}`, {
credentials: "same-origin", credentials: "same-origin",
headers: { headers: {
@ -76,9 +68,7 @@ export const getBackendAvecParametresUrl = (
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
export const deleteBackend = ( export const deleteBackend = (args: ArgumentsDeleteBackendWC): Promise<Response> =>
args: ArgumentsDeleteBackendWC,
): Promise<Response> =>
fetch(args.route, { fetch(args.route, {
credentials: "same-origin", credentials: "same-origin",
headers: { headers: {
@ -111,11 +101,7 @@ export const postBackend = (args: ArgumentsPostBackendWC): Promise<Response> =>
export const prefilledPostBackend = export const prefilledPostBackend =
(nonce: string, authString?: string) => (nonce: string, authString?: string) =>
( (route: string, body: BodyInit, needsAuthString: boolean): Promise<Response> =>
route: string,
body: BodyInit,
needsAuthString: boolean,
): Promise<Response> =>
fetch(route, { fetch(route, {
body: body, body: body,
credentials: "same-origin", credentials: "same-origin",
@ -123,32 +109,25 @@ export const prefilledPostBackend =
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
[ENTETE_WC_NONCE]: nonce, [ENTETE_WC_NONCE]: nonce,
...(authString && ...(authString && needsAuthString && { Authorization: `Basic ${authString}` }),
needsAuthString && { Authorization: `Basic ${authString}` }),
}, },
method: "POST", method: "POST",
mode: "same-origin", mode: "same-origin",
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
export const safeFetch = ( export const safeFetch = (f: Promise<Response>): EitherAsync<DOMException | TypeError, Response> =>
f: Promise<Response>,
): EitherAsync<DOMException | TypeError, Response> =>
EitherAsync<DOMException | TypeError, Response>(async () => await f); EitherAsync<DOMException | TypeError, Response>(async () => await f);
// Réponses Simplifiées // Réponses Simplifiées
export const newPartialResponse = async ( export const newPartialResponse = async (reponse: Response): Promise<SimplifiedResponse> => {
reponse: Response,
): Promise<SimplifiedResponse> => {
return { return {
body: await reponse.json(), body: await reponse.json(),
status: reponse.status, status: reponse.status,
}; };
}; };
export const traiteErreursBackendWooCommerce = ( export const traiteErreursBackendWooCommerce = (rs: SimplifiedResponse): HttpCodeErrors => {
rs: SimplifiedResponse,
): HttpCodeErrors => {
return match(rs) return match(rs)
.with({ status: 400 }, () => new BadRequestError()) .with({ status: 400 }, () => new BadRequestError())
.with({ status: 401 }, () => new UnauthorizedError()) .with({ status: 401 }, () => new UnauthorizedError())

View file

@ -2,28 +2,18 @@
import { Array as EffectArray, Match, Predicate } from "effect"; import { Array as EffectArray, Match, Predicate } from "effect";
import { import { DOM_ENTREES_MENU_CATEGORIES_PRODUITS, DOM_MENU_CATEGORIES_PRODUITS } from "./constantes/dom.ts";
DOM_ENTREES_MENU_CATEGORIES_PRODUITS, import { getAllSelectorFromDocumentOrThrow, getFirstSelectorFromDocumentOrThrow } from "../scripts-effect/lib/dom.ts";
DOM_MENU_CATEGORIES_PRODUITS,
} from "./constantes/dom.ts";
import {
getAllSelectorFromDocumentOrThrow,
getFirstSelectorFromDocumentOrThrow,
} from "../scripts-effect/lib/dom.ts";
// Initialise les attributs HTML pour l'affichage initiale des flèches de défilement du menu de catégories de Produits. // Initialise les attributs HTML pour l'affichage initiale des flèches de défilement du menu de catégories de Produits.
document.addEventListener("DOMContentLoaded", (): void => { document.addEventListener("DOMContentLoaded", (): void => {
const productsCategoriesMenu: HTMLElement = const productsCategoriesMenu: HTMLElement =
getFirstSelectorFromDocumentOrThrow<HTMLElement>( getFirstSelectorFromDocumentOrThrow<HTMLElement>(DOM_MENU_CATEGORIES_PRODUITS);
DOM_MENU_CATEGORIES_PRODUITS, const menuEntries: ReadonlyArray<HTMLAnchorElement> = getAllSelectorFromDocumentOrThrow(
DOM_ENTREES_MENU_CATEGORIES_PRODUITS,
); );
const menuEntries: ReadonlyArray<HTMLAnchorElement> =
getAllSelectorFromDocumentOrThrow(DOM_ENTREES_MENU_CATEGORIES_PRODUITS);
const firstAndLastEntries: Array<HTMLAnchorElement | undefined> = [ const firstAndLastEntries: Array<HTMLAnchorElement | undefined> = [menuEntries.at(0), menuEntries.at(-1)];
menuEntries.at(0),
menuEntries.at(-1),
];
// Créé un nouvel Observer pour la première et dernière entrée. // Créé un nouvel Observer pour la première et dernière entrée.
EffectArray.forEach(firstAndLastEntries, (menuEntry, _index) => { EffectArray.forEach(firstAndLastEntries, (menuEntry, _index) => {
@ -35,28 +25,10 @@ document.addEventListener("DOMContentLoaded", (): void => {
if (intersectionEntry.boundingClientRect.top <= 0) return; if (intersectionEntry.boundingClientRect.top <= 0) return;
Match.value([intersectionEntry.isIntersecting]).pipe( Match.value([intersectionEntry.isIntersecting]).pipe(
Match.when([true, 0], () => Match.when([true, 0], () => productsCategoriesMenu.removeAttribute("data-entrees-presentes-debut")),
productsCategoriesMenu.removeAttribute( Match.when([true, 1], () => productsCategoriesMenu.removeAttribute("data-entrees-presentes-fin")),
"data-entrees-presentes-debut", Match.when([false, 0], () => productsCategoriesMenu.setAttribute("data-entrees-presentes-debut", "")),
), Match.when([false, 1], () => productsCategoriesMenu.setAttribute("data-entrees-presentes-fin", "")),
),
Match.when([true, 1], () =>
productsCategoriesMenu.removeAttribute(
"data-entrees-presentes-fin",
),
),
Match.when([false, 0], () =>
productsCategoriesMenu.setAttribute(
"data-entrees-presentes-debut",
"",
),
),
Match.when([false, 1], () =>
productsCategoriesMenu.setAttribute(
"data-entrees-presentes-fin",
"",
),
),
Match.orElse(() => {}), Match.orElse(() => {}),
); );
}), }),

View file

@ -173,8 +173,7 @@ const ajouteProduitAuPanier = (event: MouseEvent): void => {
// Construis les arguments de la requête au backend // Construis les arguments de la requête au backend
const argsRequete: WCStoreCartAddItemArgs = { const argsRequete: WCStoreCartAddItemArgs = {
id: E.DOM_VARIATION id: E.DOM_VARIATION.map((selecteur: HTMLSelectElement): number => Number(selecteur.value))
.map((selecteur: HTMLSelectElement): number => Number(selecteur.value))
// Récupère l'ID du Produit de la Page pour les Produits simples // Récupère l'ID du Produit de la Page pour les Produits simples
.orDefault(ETATS_PAGE.idProduit), .orDefault(ETATS_PAGE.idProduit),
// id: ETATS_PAGE.idProduit, // id: ETATS_PAGE.idProduit,

View file

@ -6,61 +6,62 @@ declare(strict_types=1);
* Le modèle de la Page d'Archive d'une Catégorie de Produits. * Le modèle de la Page d'Archive d'une Catégorie de Produits.
*/ */
use HaikuAtelier\Data\Product;
use HaikuAtelier\WP\Resource;
use Timber\Timber; use Timber\Timber;
require_once __DIR__ . '/src/inc/TraitementInformations.php'; require_once __DIR__ . '/src/inc/TraitementInformations.php';
// Contexte et modèles // Contexte et modèles
$contexte = Timber::context(); $context = Timber::context();
$modeles = ['boutique.twig']; $templates = ['boutique.twig'];
/** @var list<WC_Product> $informations_produits Les informations brutes des Produits. */ /** @var WP_Term */
$informations_produits = wc_get_products([ $current_term = get_queried_object();
'category' => [get_queried_object()?->slug], $category_slug = $current_term->slug;
/** @var list<WC_Product> $raw_products Les informations brutes des Produits. */
$raw_products = wc_get_products([
'category' => [$category_slug],
'limit' => 12, 'limit' => 12,
'order' => 'DESC', 'order' => 'DESC',
'orderby' => 'date', 'orderby' => 'date',
'status' => 'publish' 'status' => 'publish'
]); ]);
/** @var InformationsProduitShop $produits Les informations strictement nécessaires pour la grille des Produits. */ $products = array_map(
$produits = array_map( callback: Product::new(...),
callback: recupere_informations_produit_shop(...), array: $raw_products
array: $informations_produits
); );
$contexte['produits'] = $produits; $context['products'] = $products;
$id_categorie_produits = array_shift($informations_produits)?->get_category_ids()[0] ?? ''; $products_category_id = array_shift($raw_products)?->get_category_ids()[0] ?? '';
$contexte['id_categorie_produits'] = $id_categorie_produits; $context['products_category_id'] = $products_category_id;
/** /**
* Charge les Scripts nécessaires pour la page d'Archive. * Charge les ressources nécessaires pour la page d'Archive.
*/ */
function charge_scripts_page_archive_produits(): void { function load_page_resources(): void {
wp_enqueue_style( Resource::enqueue_style_file(
handle: 'haiku-atelier-2024-styles-page-boutique', handle: 'haiku-atelier-2024-styles-page-boutique',
src: get_template_directory_uri() . '/assets/css/pages/page-boutique.css', path: '/assets/css/pages/page-boutique.css'
deps: [],
ver: filemtime(get_template_directory() . '/assets/css/pages/page-boutique.css'),
media: 'all'
); );
wp_enqueue_script_module( Resource::enqueue_script_module_file(
id: 'haiku-atelier-2024-scripts-page-boutique', id: 'haiku-atelier-2024-scripts-page-boutique',
src: get_template_directory_uri() . '/assets/js/scripts-page-boutique.js', path: '/assets/js/scripts-page-boutique.js'
deps: [],
version: filemtime(get_template_directory() . '/assets/js/scripts-page-boutique.js')
); );
wp_enqueue_script_module( Resource::enqueue_script_module_file(
id: 'haiku-atelier-2024-scripts-menu-categories', id: 'haiku-atelier-2024-scripts-menu-categories',
src: get_template_directory_uri() . '/assets/js/scripts-menu-categories.js', path: '/assets/js/scripts-menu-categories.js'
deps: [],
version: filemtime(get_template_directory() . '/assets/js/scripts-menu-categories.js')
); );
} }
add_action('wp_enqueue_scripts', 'charge_scripts_page_archive_produits'); add_action('wp_enqueue_scripts', 'load_page_resources');
$lal = wp_json_encode($context);
echo "<script>console.debug({$lal});</script>";
// Rendu // Rendu
Timber::render( Timber::render(
filenames: $modeles, filenames: $templates,
data: $contexte data: $context
); );

View file

@ -24,7 +24,7 @@ const _etats = {
<div class="actions"> <div class="actions">
<button <button
{{ produits|length == 12 ? '' : 'hidden' }} {{ products|length == 12 ? '' : 'hidden' }}
class="bouton-case-pleine bouton-blanc-sur-noir" class="bouton-case-pleine bouton-blanc-sur-noir"
id="bouton-plus-de-produits" id="bouton-plus-de-produits"
type="button" type="button"

View file

@ -1,24 +1,24 @@
<div class="grille-produits-similaires"> <div class="grille-produits-similaires">
{% for produit in produits_meme_collection %} {% for product in same_collection_products %}
{# TODO: Trouver une meilleure arborescence et des noms de classe #} {# TODO: Trouver une meilleure arborescence et des noms de classe #}
<article class="produit"> <article class="produit">
<figure role="figure"> <figure role="figure">
<a href="{{ produit.url }}"> <a href="{{ product.url }}">
<picture class="produit__illustration produit__illustration__principale"> <picture class="produit__illustration produit__illustration__principale">
{{ produit.default_photo }} {{ product.default_photo }}
</picture> </picture>
<picture class="produit__illustration produit__illustration__survol"> <picture class="produit__illustration produit__illustration__survol">
{{ produit.hover_photo }} {{ product.hover_photo }}
</picture> </picture>
</a> </a>
<figcaption class="produit__textuel"> <figcaption class="produit__textuel">
<h3 class="produit__textuel__titre"> <h3 class="produit__textuel__titre">
<a href="{{ produit.url }}">{{ produit.name }}</a> <a href="{{ product.url }}">{{ product.name }}</a>
</h3> </h3>
<p class="produit__textuel__prix"> <p class="produit__textuel__prix">
{{ produit.price }} {{ product.price }}
</p> </p>
</figcaption> </figcaption>
</figure> </figure>

View file

@ -9,11 +9,11 @@
id="variation-choice" id="variation-choice"
name="variation-choice" name="variation-choice"
> >
<h3 class="selecteur-produit__nom">{{ produit.name }}</h3> <h3 class="selecteur-produit__nom">{{ product.name }}</h3>
<div class="selecteur-produit__attribut-variation"> <div class="selecteur-produit__attribut-variation">
{% if produit.attributes %} {% if product.attributes %}
{% for attribut in produit.attributes %} {% for attribut in product.attributes %}
<div class="test"> <div class="test">
{{ include('parts/pages/produit/selecteur-attributs-produit.twig') }} {{ include('parts/pages/produit/selecteur-attributs-produit.twig') }}
</div> </div>
@ -56,7 +56,7 @@
#} #}
</div> </div>
<p class="selecteur-produit__prix">{{ prix_maximal ?? produit.price }}€</p> <p class="selecteur-produit__prix">{{ maximum_price ?? product.price }}€</p>
</form> </form>
</aside> </aside>
@ -79,7 +79,7 @@
class="section-textuelle__contenu" class="section-textuelle__contenu"
id="section-details-produit" id="section-details-produit"
> >
{{ produit.details }} {{ product.details }}
</div> </div>
</section> </section>
@ -122,7 +122,7 @@
<div class="details-produit__actions"> <div class="details-produit__actions">
{# Désactive le bouton d'ajout au panier en cas d'absence de stock. #} {# Désactive le bouton d'ajout au panier en cas d'absence de stock. #}
{% if produit.stock > 0 %} {% if product.stock > 0 %}
<button <button
class="bouton-case-pleine" class="bouton-case-pleine"
disabled disabled

View file

@ -3,7 +3,7 @@
aria-label="Photo of the Product alone" aria-label="Photo of the Product alone"
class="colonne colonne-gauche" class="colonne colonne-gauche"
> >
{% for photo in produit.left_column_photos %} {% for photo in product.left_column_photos %}
<figure <figure
data-index="0" data-index="0"
role="figure" role="figure"
@ -19,7 +19,7 @@
aria-label="Photos of the Product worn" aria-label="Photos of the Product worn"
class="colonne colonne-droite" class="colonne colonne-droite"
> >
{% for photo in produit.right_column_photos %} {% for photo in product.right_column_photos %}
<figure <figure
data-index="{{ loop.index }}" data-index="{{ loop.index }}"
role="figure" role="figure"

View file

@ -1,7 +1,7 @@
<div <div
class="grille-produits" class="grille-produits"
data-page="1" data-page="1"
{% if id_categorie_produits %}data-id-categorie-produits="{{ id_categorie_produits }}"{% endif %} {% if products_category_id %}data-id-categorie-produits="{{ products_category_id }}"{% endif %}
> >
{% if products|length > 0 %} {% if products|length > 0 %}
{% for product in products %} {% for product in products %}

View file

@ -13,7 +13,7 @@
/** @type {Etats} */ /** @type {Etats} */
const _etats = { const _etats = {
idProduit: {{ produit.id }}, idProduit: {{ product.id }},
nonce: "{{ nonce_wc }}", nonce: "{{ nonce_wc }}",
}; };
</script> </script>
@ -38,7 +38,7 @@
{{ include('parts/pages/produit/informations-produit.twig') }} {{ include('parts/pages/produit/informations-produit.twig') }}
{# Produits de la même Collection (Produits similaires) #} {# Produits de la même Collection (Produits similaires) #}
{% if produit.collection != '' %} {% if product.collection != '' and same_collection_products|length > 0 %}
{{ include('parts/pages/produit/produits-similaires.twig') }} {{ include('parts/pages/produit/produits-similaires.twig') }}
{% endif %} {% endif %}
{% endblock contenu %} {% endblock contenu %}