From 319dfe357a0fcafeb2a30f466070eef5ed3dc785 Mon Sep 17 00:00:00 2001 From: magdev Date: Wed, 21 Jan 2026 21:58:54 +0100 Subject: [PATCH] Fix license generation and checkout domain field bugs - Add WooCommerce Checkout Blocks support for domain field - Create CheckoutBlocksIntegration for block-based checkout - Create StoreApiExtension for Store API domain handling - Add checkout-blocks.js for frontend domain field in blocks - Fix LicenseManager product type check in generateLicense() - Add multiple order status hooks for reliable license generation Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 35 +++++- assets/js/checkout-blocks.js | 100 +++++++++++++++ src/Checkout/CheckoutBlocksIntegration.php | 127 +++++++++++++++++++ src/Checkout/StoreApiExtension.php | 134 +++++++++++++++++++++ src/License/LicenseManager.php | 7 +- src/Plugin.php | 27 ++++- 6 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 assets/js/checkout-blocks.js create mode 100644 src/Checkout/CheckoutBlocksIntegration.php create mode 100644 src/Checkout/StoreApiExtension.php diff --git a/CLAUDE.md b/CLAUDE.md index ba81977..a5bb190 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,13 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w ### Known Bugs -_No known bugs at this time. +_No known bugs at this time._ + +### Version 0.0.10 + +- Add a license related form section to the orders form in the admin area +- Make license domains editable in the backend +- Investigate checkout block integration improvements if issues persist ## Technical Stack @@ -480,3 +486,30 @@ Full API documentation available in `openapi.json` (OpenAPI 3.1 specification). - JavaScript client works in both browser and Node.js environments - Python client uses dataclasses for type-safe responses - C# client uses async/await patterns and System.Text.Json + +### 2026-01-21 - Bug Fixes (pre-v0.0.10) + +**Fixed bugs:** + +- Fixed: No licenses generated when order is marked as done +- Fixed: No domain field in WooCommerce Checkout Blocks + +**New files:** + +- `src/Checkout/CheckoutBlocksIntegration.php` - WooCommerce Blocks checkout integration +- `src/Checkout/StoreApiExtension.php` - Store API extension for domain field handling +- `assets/js/checkout-blocks.js` - Frontend JavaScript for checkout block domain field + +**Modified files:** + +- `src/Plugin.php` - Added checkout blocks registration and multiple order status hooks +- `src/License/LicenseManager.php` - Fixed product type check in `generateLicense()` + +**Technical notes:** + +- Added support for WooCommerce Checkout Blocks (default since WC 8.3+) +- `CheckoutBlocksIntegration` implements `IntegrationInterface` for block checkout +- `StoreApiExtension` handles server-side domain data via Store API +- License generation now triggers on `completed`, `processing`, and `payment_complete` hooks +- Fixed `LicenseManager::generateLicense()` to properly check product type with `is_type()` +- Classic checkout (`woocommerce_after_order_notes`) still works for stores using classic checkout diff --git a/assets/js/checkout-blocks.js b/assets/js/checkout-blocks.js new file mode 100644 index 0000000..83053f0 --- /dev/null +++ b/assets/js/checkout-blocks.js @@ -0,0 +1,100 @@ +/** + * WooCommerce Checkout Blocks Integration + * + * Adds a domain field to the checkout block for licensed products. + * + * @package WcLicensedProduct + */ + +(function () { + 'use strict'; + + const { registerCheckoutBlock } = wc.blocksCheckout; + const { createElement, useState, useEffect } = wp.element; + const { TextControl } = wp.components; + const { __ } = wp.i18n; + const { extensionCartUpdate } = wc.blocksCheckout; + const { getSetting } = wc.wcSettings; + + // Get settings passed from PHP + const settings = getSetting('wc-licensed-product_data', {}); + + /** + * Validate domain format + */ + function isValidDomain(domain) { + if (!domain || domain.length > 255) { + return false; + } + const pattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; + return pattern.test(domain); + } + + /** + * Normalize domain (remove protocol and www) + */ + function normalizeDomain(domain) { + let normalized = domain.toLowerCase().trim(); + normalized = normalized.replace(/^https?:\/\//, ''); + normalized = normalized.replace(/^www\./, ''); + normalized = normalized.replace(/\/.*$/, ''); + return normalized; + } + + /** + * License Domain Block Component + */ + const LicenseDomainBlock = ({ checkoutExtensionData, extensions }) => { + const [domain, setDomain] = useState(''); + const [error, setError] = useState(''); + const { setExtensionData } = checkoutExtensionData; + + // Only show if cart has licensed products + if (!settings.hasLicensedProducts) { + return null; + } + + const handleChange = (value) => { + const normalized = normalizeDomain(value); + setDomain(normalized); + + // Validate + if (normalized && !isValidDomain(normalized)) { + setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product')); + } else { + setError(''); + } + + // Update extension data for server-side processing + setExtensionData('wc-licensed-product', 'licensed_product_domain', normalized); + }; + + return createElement( + 'div', + { className: 'wc-block-components-licensed-product-domain' }, + createElement( + 'h3', + { className: 'wc-block-components-title' }, + settings.sectionTitle || __('License Domain', 'wc-licensed-product') + ), + createElement(TextControl, { + label: settings.fieldLabel || __('Domain for License Activation', 'wc-licensed-product'), + value: domain, + onChange: handleChange, + placeholder: settings.fieldPlaceholder || 'example.com', + help: error || settings.fieldDescription || __('Enter the domain where you will use this license.', 'wc-licensed-product'), + className: error ? 'has-error' : '', + required: true, + }) + ); + }; + + // Register the checkout block + registerCheckoutBlock({ + metadata: { + name: 'wc-licensed-product/domain-field', + parent: ['woocommerce/checkout-contact-information-block'], + }, + component: LicenseDomainBlock, + }); +})(); diff --git a/src/Checkout/CheckoutBlocksIntegration.php b/src/Checkout/CheckoutBlocksIntegration.php new file mode 100644 index 0000000..cb759f0 --- /dev/null +++ b/src/Checkout/CheckoutBlocksIntegration.php @@ -0,0 +1,127 @@ +registerScripts(); + $this->registerBlockExtensionData(); + } + + /** + * Register scripts for the checkout block + */ + private function registerScripts(): void + { + $scriptPath = WC_LICENSED_PRODUCT_PLUGIN_DIR . 'assets/js/checkout-blocks.js'; + $scriptUrl = WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/js/checkout-blocks.js'; + + if (file_exists($scriptPath)) { + wp_register_script( + 'wc-licensed-product-checkout-blocks', + $scriptUrl, + ['wc-blocks-checkout', 'wp-element', 'wp-components', 'wp-i18n'], + WC_LICENSED_PRODUCT_VERSION, + true + ); + + wp_set_script_translations( + 'wc-licensed-product-checkout-blocks', + 'wc-licensed-product', + WC_LICENSED_PRODUCT_PLUGIN_DIR . 'languages' + ); + } + } + + /** + * Register block extension data + */ + private function registerBlockExtensionData(): void + { + // Pass data to the checkout block script + add_filter( + 'woocommerce_blocks_checkout_block_registration_data', + function (array $data): array { + $data['wc-licensed-product'] = [ + 'hasLicensedProducts' => $this->cartHasLicensedProducts(), + ]; + return $data; + } + ); + } + + /** + * Returns an array of script handles to enqueue in the frontend context + */ + public function get_script_handles(): array + { + return ['wc-licensed-product-checkout-blocks']; + } + + /** + * Returns an array of script handles to enqueue in the editor context + */ + public function get_editor_script_handles(): array + { + return []; + } + + /** + * Returns script data to pass to the frontend scripts + */ + public function get_script_data(): array + { + return [ + 'hasLicensedProducts' => $this->cartHasLicensedProducts(), + 'fieldLabel' => __('Domain for License Activation', 'wc-licensed-product'), + 'fieldPlaceholder' => __('example.com', 'wc-licensed-product'), + 'fieldDescription' => __('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'), + 'sectionTitle' => __('License Domain', 'wc-licensed-product'), + 'validationError' => __('Please enter a valid domain for your license activation.', 'wc-licensed-product'), + ]; + } + + /** + * Check if cart contains licensed products + */ + private function cartHasLicensedProducts(): bool + { + if (!WC()->cart) { + return false; + } + + foreach (WC()->cart->get_cart() as $cartItem) { + $product = $cartItem['data']; + if ($product && $product->is_type('licensed')) { + return true; + } + } + + return false; + } +} diff --git a/src/Checkout/StoreApiExtension.php b/src/Checkout/StoreApiExtension.php new file mode 100644 index 0000000..564f569 --- /dev/null +++ b/src/Checkout/StoreApiExtension.php @@ -0,0 +1,134 @@ +licenseManager = $licenseManager; + $this->registerHooks(); + } + + /** + * Register Store API hooks + */ + private function registerHooks(): void + { + add_action('woocommerce_blocks_loaded', [$this, 'registerStoreApiExtension']); + } + + /** + * Register the Store API extension + */ + public function registerStoreApiExtension(): void + { + if (!class_exists('Automattic\WooCommerce\StoreApi\StoreApi')) { + return; + } + + // Register endpoint data extension + woocommerce_store_api_register_endpoint_data([ + 'endpoint' => CheckoutSchema::IDENTIFIER, + 'namespace' => self::IDENTIFIER, + 'data_callback' => [$this, 'getExtensionData'], + 'schema_callback' => [$this, 'getExtensionSchema'], + 'schema_type' => ARRAY_A, + ]); + + // Register update callback for the domain field + woocommerce_store_api_register_update_callback([ + 'namespace' => self::IDENTIFIER, + 'callback' => [$this, 'handleExtensionUpdate'], + ]); + + // Hook into checkout order processing + add_action('woocommerce_store_api_checkout_order_processed', [$this, 'processCheckoutOrder']); + } + + /** + * Get extension data for the checkout endpoint + */ + public function getExtensionData(): array + { + return [ + 'licensed_product_domain' => WC()->session ? WC()->session->get('licensed_product_domain', '') : '', + ]; + } + + /** + * Get extension schema + */ + public function getExtensionSchema(): array + { + return [ + 'licensed_product_domain' => [ + 'description' => __('Domain for license activation', 'wc-licensed-product'), + 'type' => 'string', + 'context' => ['view', 'edit'], + 'readonly' => false, + ], + ]; + } + + /** + * Handle extension data updates from the frontend + */ + public function handleExtensionUpdate(array $data): void + { + if (isset($data['licensed_product_domain'])) { + $domain = sanitize_text_field($data['licensed_product_domain']); + $normalizedDomain = $this->licenseManager->normalizeDomain($domain); + + if (WC()->session) { + WC()->session->set('licensed_product_domain', $normalizedDomain); + } + } + } + + /** + * Process the checkout order - save domain to order meta + */ + public function processCheckoutOrder(\WC_Order $order): void + { + $domain = WC()->session ? WC()->session->get('licensed_product_domain', '') : ''; + + // Also check in the request data for block checkout + if (empty($domain)) { + $requestData = json_decode(file_get_contents('php://input'), true); + if (isset($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain'])) { + $domain = sanitize_text_field($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain']); + $domain = $this->licenseManager->normalizeDomain($domain); + } + } + + if (!empty($domain)) { + $order->update_meta_data('_licensed_product_domain', $domain); + $order->save(); + + // Clear session data + if (WC()->session) { + WC()->session->set('licensed_product_domain', ''); + } + } + } +} diff --git a/src/License/LicenseManager.php b/src/License/LicenseManager.php index 50a30a1..2dea398 100644 --- a/src/License/LicenseManager.php +++ b/src/License/LicenseManager.php @@ -56,10 +56,15 @@ class LicenseManager } $product = wc_get_product($productId); - if (!$product instanceof LicensedProduct) { + if (!$product || !$product->is_type('licensed')) { return null; } + // Ensure we have the LicensedProduct instance for type hints + if (!$product instanceof LicensedProduct) { + $product = new LicensedProduct($productId); + } + // Generate unique license key $licenseKey = $this->generateLicenseKey(); while ($this->getLicenseByKey($licenseKey)) { diff --git a/src/Plugin.php b/src/Plugin.php index 4204bd9..35adf39 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -13,7 +13,9 @@ use Jeremias\WcLicensedProduct\Admin\AdminController; use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController; use Jeremias\WcLicensedProduct\Api\RestApiController; +use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration; use Jeremias\WcLicensedProduct\Checkout\CheckoutController; +use Jeremias\WcLicensedProduct\Checkout\StoreApiExtension; use Jeremias\WcLicensedProduct\Email\LicenseEmailController; use Jeremias\WcLicensedProduct\Frontend\AccountController; use Jeremias\WcLicensedProduct\Frontend\DownloadController; @@ -110,6 +112,8 @@ final class Plugin // Initialize controllers new LicensedProductType(); new CheckoutController($this->licenseManager); + new StoreApiExtension($this->licenseManager); + $this->registerCheckoutBlocksIntegration(); $this->downloadController = new DownloadController($this->licenseManager, $this->versionManager); new AccountController($this->twig, $this->licenseManager, $this->versionManager, $this->downloadController); new RestApiController($this->licenseManager); @@ -122,13 +126,34 @@ final class Plugin } } + /** + * Register WooCommerce Checkout Blocks integration + */ + private function registerCheckoutBlocksIntegration(): void + { + add_action('woocommerce_blocks_loaded', function (): void { + if (class_exists('Automattic\WooCommerce\Blocks\Integrations\IntegrationRegistry')) { + add_action( + 'woocommerce_blocks_checkout_block_registration', + function ($integration_registry): void { + $integration_registry->register(new CheckoutBlocksIntegration()); + } + ); + } + }); + } + /** * Register plugin hooks */ private function registerHooks(): void { - // Generate license on order completion + // Generate license on order completion (multiple hooks for compatibility) add_action('woocommerce_order_status_completed', [$this, 'onOrderCompleted']); + add_action('woocommerce_order_status_processing', [$this, 'onOrderCompleted']); + + // Also hook into payment complete for immediate license generation + add_action('woocommerce_payment_complete', [$this, 'onOrderCompleted']); } /**