diff --git a/docs/research/12-elementor-parity-matrix.md b/docs/research/12-elementor-parity-matrix.md index 65c455e..6965ae0 100644 --- a/docs/research/12-elementor-parity-matrix.md +++ b/docs/research/12-elementor-parity-matrix.md @@ -62,8 +62,14 @@ icon/icon-box/list ✓ MA libreria icone gestita (carica/SVG set/selettore) ⚠ 1. **Theme builder template system** (header/footer/archive/single/404 come template con condizioni di display): le PRIMITIVE c esistono gi (wp-\* + nav-menu + condizioni del builder menu), manca il CRUD UI dei template-part e le conditions. È il pezzo che rende il confronto possibile con Elementor Pro. -2. **WooCommerce** (loro pagina lista ~15 widget prodotto free/pro): 0 coverage. Il gap singolo - più grande. Sottoinsieme ragionato: product-grid, add-to-cart, gallery, meta — su islands runtime. +2. **WooCommerce** — sottinsieme v1 FATTO (feat/woo-blocks): product-grid (columns closed-set, + category slug, on-sale, orderby), product-featured (per productId), product-categories — tutti + statici, registrati solo con class_exists('WooCommerce'). L'add-to-cart usa l'URL nativo + Woo ?add-to-cart=ID: zero JS custom, niente island (l'island restava per la galleria, non fatta + in v1). Lezione dev-site: la pagina carrello creata da Woo e' il blocco JS-rendered -> per il + test flow classico si e' messo [woocommerce_cart] nel contenuto; hello theme non stampa le + notices fuori dai template Woo; wc_create_product esiste solo in admin (CLI: usare + WC_Product_Simple->save()); nell'handler reale il hook e' wp_loaded priorita' 20. 3. **Icon library gestita** — FATTA (feat/icon-library): 1838 Lucide (ISC) via manifest JSON nel plugin, endpoint GET/POST blocky/v1/icons, upload SVG con sanitizer DOM allowlist proprio (wp_kses_svg NON esiste nel core: verificare sempre prima di citarlo), controllo inspector diff --git a/e2e/woo.spec.ts b/e2e/woo.spec.ts new file mode 100644 index 0000000..3a2f892 --- /dev/null +++ b/e2e/woo.spec.ts @@ -0,0 +1,117 @@ +import { execFileSync } from 'node:child_process'; +import { expect, test } from '@playwright/test'; + +import { + builderPreviewFrame, + ensureCanvasBlock, + loginToBuilder, + selectCanvasBlock, +} from './helpers/builder'; + +/* + * WooCommerce subset (matrix item 5). All product markup is static and + * add-to-cart uses WooCommerce's native ?add-to-cart URL — the test follows + * the real click path into the cart page, no custom JS involved. + */ + +const SITE = process.env.BLOCKY_E2E_SITE ?? 'http://192.168.1.8:8888'; +const CONTAINER = process.env.BLOCKY_E2E_CONTAINER ?? 'local-wp-wp-1'; + +function wp(args: string[]): string { + return execFileSync('docker', ['exec', CONTAINER, 'wp', ...args, '--allow-root'], { + encoding: 'utf-8', + }).trim(); +} + +let postId = 0; +let widgetTeeId = 0; +let hoodieId = 0; + +test.beforeAll(() => { + wp(['eval-file', '/tmp/woo-products2.php']); + const out = wp([ + 'eval', + 'foreach (array("Widget Tee","Panel Hoodie") as $n) { $p = get_page_by_title($n, OBJECT, "product"); echo ($p ? $p->ID : 0), " "; }', + ]); + const ids = out.split(/\s+/).map(Number); + widgetTeeId = ids[0] ?? 0; + hoodieId = ids[1] ?? 0; + postId = Number( + wp([ + 'post', + 'create', + '--post_title=Woo E2E', + '--post_type=page', + '--post_status=publish', + '--porcelain', + ]) + ); +}); + +test.afterAll(() => { + if (postId) wp(['post', 'delete', String(postId), '--force']); +}); + +function toolbar(page: import('@playwright/test').Page) { + return page.locator('header'); +} + +async function saveDocument(page: import('@playwright/test').Page) { + await expect(toolbar(page).getByRole('button', { name: 'Save', exact: true })).toBeEnabled({ + timeout: 5000, + }); + const saved = page.waitForResponse( + (r) => + new URL(r.url()).pathname.endsWith('/documents/' + postId) && r.request().method() === 'POST', + { timeout: 30000 } + ); + await toolbar(page).getByRole('button', { name: 'Save', exact: true }).click(); + expect((await saved).status()).toBe(200); +} + +test.describe('WooCommerce blocks', () => { + test('product grid renders products and the real add-to-cart click reaches the cart', async ({ + page, + }) => { + test.skip(widgetTeeId === 0, 'products missing'); + await loginToBuilder(page, { postId }); + await ensureCanvasBlock(page, 'bky/product-grid'); + + const frame = builderPreviewFrame(page); + await expect(frame.getByText('Widget Tee').first()).toBeVisible(); + await expect(frame.locator('a[href*="add-to-cart="]').first()).toBeVisible(); + + await saveDocument(page); + + await page.goto(SITE + '/?page_id=' + postId, { waitUntil: 'networkidle' }); + await expect(page.getByRole('link', { name: 'Widget Tee' }).first()).toBeVisible(); + await page + .locator('article', { hasText: 'Widget Tee' }) + .getByRole('link', { name: 'Add to cart' }) + .click(); + // Woo's default add-to-cart does not redirect (and the hello theme prints + // notices only inside Woo templates), so the classic verification point is + // the cart page itself — the clicked product must be listed there. + await page.goto(SITE + '/cart/', { waitUntil: 'networkidle' }); + await expect(page.getByRole('link', { name: 'Widget Tee' })).toBeVisible(); + }); + + test('featured product shows the product selected by id', async ({ page }) => { + test.skip(hoodieId === 0, 'products missing'); + await loginToBuilder(page, { postId }); + await ensureCanvasBlock(page, 'bky/product-featured'); + await selectCanvasBlock(page, 'bky/product-featured'); + + await page.locator('aside label:has-text("Product ID") input').fill(String(hoodieId)); + + const frame = builderPreviewFrame(page); + await expect(frame.getByRole('heading', { name: 'Panel Hoodie', level: 2 })).toBeVisible({ + timeout: 10000, + }); + + await saveDocument(page); + + await page.goto(SITE + '/?page_id=' + postId, { waitUntil: 'networkidle' }); + await expect(page.getByRole('heading', { name: 'Panel Hoodie', level: 2 })).toBeVisible(); + }); +}); diff --git a/packages/core-plugin/phpstan-baseline.neon b/packages/core-plugin/phpstan-baseline.neon index c13177a..367e470 100644 --- a/packages/core-plugin/phpstan-baseline.neon +++ b/packages/core-plugin/phpstan-baseline.neon @@ -7,7 +7,7 @@ parameters: - message: "#^Parameter \\$variants of class Blocky\\\\Core\\\\Blocks\\\\BlockDefinition constructor expects array\\\\>, array\\\\> given\\.$#" - count: 1 + count: 2 path: src/Blocks/Registry.php - diff --git a/packages/core-plugin/phpstan.neon b/packages/core-plugin/phpstan.neon index 2240583..5b73b3f 100644 --- a/packages/core-plugin/phpstan.neon +++ b/packages/core-plugin/phpstan.neon @@ -10,4 +10,5 @@ parameters: - tests/phpstan-bootstrap.php scanFiles: - tests/phpstan/wp-cli-stub.php + - tests/phpstan/woocommerce-stub.php ignoreErrors: [] diff --git a/packages/core-plugin/src/Blocks/Registry.php b/packages/core-plugin/src/Blocks/Registry.php index a7c5fa4..dcd6b7f 100644 --- a/packages/core-plugin/src/Blocks/Registry.php +++ b/packages/core-plugin/src/Blocks/Registry.php @@ -11,6 +11,9 @@ use Blocky\Core\Blocks\Renderer\BlockRendererInterface; use Blocky\Core\Blocks\Renderers\HeadingRenderer; +use Blocky\Core\Blocks\Renderers\ProductGridRenderer; +use Blocky\Core\Blocks\Renderers\ProductFeaturedRenderer; +use Blocky\Core\Blocks\Renderers\ProductCategoriesRenderer; use Blocky\Core\Blocks\Renderers\SectionRenderer; use Blocky\Core\Blocks\Renderers\TextRenderer; use Blocky\Core\Blocks\Renderers\ButtonRenderer; @@ -279,6 +282,12 @@ public function registerCoreBlocks(): void $this->register(self::makeDataField()); $this->register(self::makeWpHook()); $this->register(self::makeThemeToggle()); + + if (class_exists('WooCommerce')) { + $this->register(self::makeProductGrid()); + $this->register(self::makeProductFeatured()); + $this->register(self::makeProductCategories()); + } } /** @@ -2102,6 +2111,102 @@ private static function makePostsGrid(): BlockDefinition ); } + private static function makeProductGrid(): BlockDefinition + { + return new BlockDefinition( + type: 'bky/product-grid', + label: self::tr('Product Grid'), + category: 'basic', + description: self::tr('Grid of WooCommerce products with static add-to-cart links.'), + keywords: ['shop', 'woo', 'products'], + icon: '\u{1F6D2}', + schema: [ + 'type' => 'object', + 'properties' => [ + 'number' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 24, 'default' => 6], + 'category' => ['type' => 'string', 'default' => ''], + 'orderby' => ['type' => 'string', 'enum' => ['date', 'price', 'popularity', 'title'], 'default' => 'date'], + 'onSaleOnly' => ['type' => 'boolean', 'default' => false], + 'showPrice' => ['type' => 'boolean', 'default' => true], + 'showAddToCart' => ['type' => 'boolean', 'default' => true], + 'columns' => ['type' => 'string', 'enum' => ['2', '3', '4'], 'default' => '3'], + ], + ], + variants: [ + 'columns' => ['2' => 'grid-cols-1 md:grid-cols-2', '3' => 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', '4' => 'grid-cols-1 md:grid-cols-2 xl:grid-cols-4'], + ], + renderer: new ProductGridRenderer(), + editorConfig: self::localizeEditorConfig([ + 'tabs' => [ + ['id' => 'content', 'label' => 'Content', 'controls' => [ + ['id' => 'number', 'type' => 'number', 'label' => 'Products', 'min' => 1, 'max' => 24], + ['id' => 'category', 'type' => 'text', 'label' => 'Category Slug'], + ['id' => 'orderby', 'type' => 'select', 'label' => 'Order By', 'options' => [['date', 'Newest'], ['price', 'Price'], ['popularity', 'Popularity'], ['title', 'Title']]], + ['id' => 'onSaleOnly', 'type' => 'toggle', 'label' => 'On Sale Only'], + ['id' => 'showPrice', 'type' => 'toggle', 'label' => 'Show Price'], + ['id' => 'showAddToCart', 'type' => 'toggle', 'label' => 'Add to Cart Button'], + ]], + ['id' => 'layout', 'label' => 'Layout', 'controls' => [ + ['id' => 'columns', 'type' => 'variant', 'label' => 'Columns', 'variantKey' => 'columns'], + ]], + ], + ]), + ); + } + private static function makeProductFeatured(): BlockDefinition + { + return new BlockDefinition( + type: 'bky/product-featured', + label: self::tr('Featured Product'), + category: 'basic', + description: self::tr('Spotlight one WooCommerce product with image, price and add-to-cart.'), + keywords: ['shop', 'woo', 'product'], + icon: '\u{2B50}', + schema: [ + 'type' => 'object', + 'properties' => [ + 'productId' => ['type' => 'integer', 'minimum' => 0, 'default' => 0], + 'imageSide' => ['type' => 'string', 'enum' => ['left', 'right'], 'default' => 'left'], + ], + ], + variants: [], + renderer: new ProductFeaturedRenderer(), + editorConfig: self::localizeEditorConfig([ + 'tabs' => [ + ['id' => 'content', 'label' => 'Content', 'controls' => [ + ['id' => 'productId', 'type' => 'number', 'label' => 'Product ID', 'min' => 0], + ['id' => 'imageSide', 'type' => 'select', 'label' => 'Image Side', 'options' => [['left', 'Left'], ['right', 'Right']]], + ]], + ], + ]), + ); + } + private static function makeProductCategories(): BlockDefinition + { + return new BlockDefinition( + type: 'bky/product-categories', + label: self::tr('Product Categories'), + category: 'basic', + description: self::tr('Links to WooCommerce product categories with counts.'), + keywords: ['shop', 'woo', 'categories'], + icon: '\u{1F3F7}', + schema: [ + 'type' => 'object', + 'properties' => [ + 'number' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 30, 'default' => 8], + ], + ], + variants: [], + renderer: new ProductCategoriesRenderer(), + editorConfig: self::localizeEditorConfig([ + 'tabs' => [ + ['id' => 'content', 'label' => 'Content', 'controls' => [ + ['id' => 'number', 'type' => 'number', 'label' => 'Maximum', 'min' => 1, 'max' => 30], + ]], + ], + ]), + ); + } private static function makeFeaturedPosts(): BlockDefinition { return new BlockDefinition( diff --git a/packages/core-plugin/src/Blocks/Renderers/ProductCategoriesRenderer.php b/packages/core-plugin/src/Blocks/Renderers/ProductCategoriesRenderer.php new file mode 100644 index 0000000..ba0a58b --- /dev/null +++ b/packages/core-plugin/src/Blocks/Renderers/ProductCategoriesRenderer.php @@ -0,0 +1,63 @@ +blockAttrs( $node, array( 'class' => 'text-sm text-text-muted' ) ), + \esc_html__( 'WooCommerce is not active on this site.', 'blocky' ) + ); + } + + $terms = \get_terms( array( + 'taxonomy' => 'product_cat', + 'hide_empty' => true, + 'number' => max( 1, min( 30, (int) ( $node->props['number'] ?? 8 ) ) ), + ) ); + if ( ! is_array( $terms ) || array() === $terms ) { + return HtmlString::element( + 'div', + $ctx->blockAttrs( $node, array( 'class' => 'text-sm text-text-muted' ) ), + \esc_html__( 'No product categories yet.', 'blocky' ) + ); + } + + $items = ''; + foreach ( $terms as $term ) { + $link = \get_term_link( $term ); + if ( \is_wp_error( $link ) ) { + continue; + } + $items .= '
  • ' . esc_html( $term->name ) + . '' . (int) $term->count . '
  • '; + } + + return HtmlString::element( + 'div', + $ctx->blockAttrs( $node, array( 'class' => '' ) ), + '' + ); + } +} diff --git a/packages/core-plugin/src/Blocks/Renderers/ProductFeaturedRenderer.php b/packages/core-plugin/src/Blocks/Renderers/ProductFeaturedRenderer.php new file mode 100644 index 0000000..12973a5 --- /dev/null +++ b/packages/core-plugin/src/Blocks/Renderers/ProductFeaturedRenderer.php @@ -0,0 +1,60 @@ +blockAttrs( $node, array( 'class' => 'text-sm text-text-muted' ) ), + \esc_html__( 'Select a WooCommerce product to feature.', 'blocky' ) + ); + + if ( ! class_exists( 'WooCommerce' ) || ! function_exists( 'wc_get_product' ) ) { + return $notice; + } + + $product = wc_get_product( (int) ( $node->props['productId'] ?? 0 ) ); + if ( ! $product instanceof \WC_Product ) { + return $notice; + } + + $image_side = ( 'right' === (string) ( $node->props['imageSide'] ?? 'left' ) ) ? 'lg:flex-row-reverse' : ''; + $thumb = get_the_post_thumbnail( $product->get_id(), 'large', array( 'class' => 'w-full rounded-card object-cover lg:w-1/2', 'loading' => 'lazy' ) ); + $price = function_exists( 'wc_price' ) ? wc_price( wp_strip_all_tags( (string) $product->get_price_html() ) ) : ''; + $desc = trim( wp_strip_all_tags( (string) $product->get_short_description() ) ); + + $inner = ( '' !== $thumb ? (string) $thumb : '' ) + . ''; + + return HtmlString::element( + 'div', + $ctx->blockAttrs( $node, array( 'class' => 'flex flex-col gap-6 lg:flex-row lg:items-center ' . $image_side ) ), + $inner + ); + } +} diff --git a/packages/core-plugin/src/Blocks/Renderers/ProductGridRenderer.php b/packages/core-plugin/src/Blocks/Renderers/ProductGridRenderer.php new file mode 100644 index 0000000..ca38d58 --- /dev/null +++ b/packages/core-plugin/src/Blocks/Renderers/ProductGridRenderer.php @@ -0,0 +1,109 @@ +blockAttrs( $node, array( 'class' => 'text-sm text-text-muted' ) ), + \esc_html__( 'WooCommerce is not active on this site.', 'blocky' ) + ); + } + + $args = array( + 'post_type' => 'product', + 'post_status' => 'publish', + 'posts_per_page' => max( 1, min( 24, (int) ( $node->props['number'] ?? 6 ) ) ), + 'orderby' => (string) ( $node->props['orderby'] ?? 'date' ), + ); + $category = trim( (string) ( $node->props['category' ] ?? '' ) ); + if ( '' !== $category ) { + $args['tax_query'] = array( + array( + 'taxonomy' => 'product_cat', + 'field' => 'slug', + 'terms' => $category, + ), + ); + } + if ( ! empty( $node->props['onSaleOnly'] ) && function_exists( 'wc_get_product_ids_on_sale' ) ) { + $args['post__in'] = wc_get_product_ids_on_sale(); + } + + $query = new \WP_Query( $args ); + if ( ! $query->have_posts() ) { + \wp_reset_postdata(); + return HtmlString::element( + 'div', + $ctx->blockAttrs( $node, array( 'class' => 'text-sm text-text-muted' ) ), + \esc_html__( 'No products found.', 'blocky' ) + ); + } + + /** @var array $columns_map */ + $columns_map = array( + '2' => 'grid-cols-1 md:grid-cols-2', + '3' => 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', + '4' => 'grid-cols-1 md:grid-cols-2 xl:grid-cols-4', + ); + $columns_class = $ctx->resolveVariantClasses( $node, array( 'columns' => $columns_map ) ); + + $show_price = (bool) ( $node->props['showPrice'] ?? true ); + $show_cart = (bool) ( $node->props['showAddToCart'] ?? true ); + + $cards = ''; + while ( $query->have_posts() ) { + $query->the_post(); + $post_id = (int) get_the_ID(); + $product = function_exists( 'wc_get_product' ) ? wc_get_product( $post_id ) : null; + if ( null === $product ) { + continue; + } + $link = (string) get_permalink(); + $thumb = (string) get_the_post_thumbnail( $post_id, 'woocommerce_thumbnail', array( 'class' => 'w-full rounded-base object-cover', 'loading' => 'lazy' ) ); + $price = ( $show_price && function_exists( 'wc_price' ) ) ? wc_price( $product->get_price_html() ? wp_strip_all_tags( $product->get_price_html() ) : '' ) : ''; + $badge = ( function_exists( 'wc_get_product_is_on_sale' ) && $product->is_on_sale() ) + ? '' . esc_html__( 'Sale', 'blocky' ) . '' + : ''; + $button = $show_cart + ? '' + . esc_html__( 'Add to cart', 'blocky' ) . '' + : ''; + + $cards .= ''; + } + \wp_reset_postdata(); + + return HtmlString::element( + 'div', + $ctx->blockAttrs( $node, array( 'class' => 'grid gap-4 ' . $columns_class ) ), + $cards + ); + } +} diff --git a/packages/core-plugin/tests/phpstan/woocommerce-stub.php b/packages/core-plugin/tests/phpstan/woocommerce-stub.php new file mode 100644 index 0000000..c68c82b --- /dev/null +++ b/packages/core-plugin/tests/phpstan/woocommerce-stub.php @@ -0,0 +1,27 @@ +