diff --git a/CHANGELOG.md b/CHANGELOG.md index ddcea90..e501ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.10] - 2026-01-21 + +### Added + +- License meta box on WooCommerce order edit pages +- Editable order domain field with AJAX save +- Editable license domains directly from order page +- View licenses table showing all licenses for an order +- Link to full licenses management page from order view +- Support for both classic orders and HPOS (High-Performance Order Storage) + +### Technical Details + +- New `OrderLicenseController` class for order page integration +- New `getLicensesByOrder()` method in LicenseManager +- JavaScript file `order-licenses.js` for inline domain editing +- AJAX handlers for updating order domain and license domains + +## [0.0.9] - 2026-01-21 + +### Added + +- API client examples for multiple programming languages +- cURL command examples (`docs/client-examples/curl.sh`) +- PHP client class (`docs/client-examples/php-client.php`) +- Python client class (`docs/client-examples/python-client.py`) +- JavaScript/Node.js client (`docs/client-examples/javascript-client.js`) +- C# client class (`docs/client-examples/csharp-client.cs`) +- Client examples documentation (`docs/client-examples/README.md`) + +### Technical Details + +- All clients include rate limiting handling (HTTP 429) +- Examples demonstrate validate, status, and activate endpoints +- JavaScript client works in both browser and Node.js environments +- Python client uses dataclasses for type-safe responses +- C# client uses async/await patterns + +## [0.0.8] - 2026-01-21 + +### Changed + +- Current version now automatically derived from latest product version +- Email system refactored to use WooCommerce transactional emails +- License expiration warning email now configurable via WooCommerce email settings + +### Removed + +- "Current Version" field from product license settings panel + +### Technical Details + +- New `LicenseExpirationEmail` class extends WC_Email +- LicensedProduct's `get_current_version()` queries VersionManager +- Uses WooCommerce email header/footer templates +- Warning days configurable in plugin settings + ## [0.0.7] - 2026-01-21 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 75e55f9..9d1a57d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,10 +36,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w None currently known. -### Version 0.0.10 +### Version 0.0.11 (planned) -- Add a license related form section to the orders form in the admin area -- Make license domains editable in the backend +- TBD - no specific features planned yet ## Technical Stack @@ -527,3 +526,30 @@ Full API documentation available in `openapi.json` (OpenAPI 3.1 specification). - `$wpdb->insert()` now only includes `attachment_id` field when it has a valid value - Added error logging for version creation failures to aid debugging - Improved meta box visibility logic for new products + +### 2026-01-21 - Version 0.0.10 Features + +**Implemented:** + +- License meta box on WooCommerce order edit pages +- Editable order domain field with AJAX-based inline editing +- Editable license domains directly from the order page +- Licenses table showing all licenses associated with an order +- Support for both classic orders and HPOS (High-Performance Order Storage) + +**New files:** + +- `src/Admin/OrderLicenseController.php` - Order page license integration +- `assets/js/order-licenses.js` - JavaScript for inline domain editing + +**New methods in LicenseManager:** + +- `getLicensesByOrder()` - Get all licenses for a specific order + +**Technical notes:** + +- Meta box automatically detects HPOS vs classic order storage +- AJAX handlers for updating both order domain and individual license domains +- Domain validation with normalization (strips protocol, www prefix) +- Inline edit UI with save/cancel for license domains +- Links to full licenses management page for advanced actions diff --git a/assets/js/order-licenses.js b/assets/js/order-licenses.js new file mode 100644 index 0000000..13e13c0 --- /dev/null +++ b/assets/js/order-licenses.js @@ -0,0 +1,206 @@ +/** + * WC Licensed Product - Order Licenses Admin + * + * @package Jeremias\WcLicensedProduct + */ + +(function($) { + 'use strict'; + + var WCLPOrderLicenses = { + init: function() { + this.bindEvents(); + }, + + bindEvents: function() { + // Order domain save + $('#wclp-save-order-domain').on('click', this.saveOrderDomain.bind(this)); + + // License domain edit/save/cancel + $(document).on('click', '.wclp-edit-domain-btn', this.startEditDomain); + $(document).on('click', '.wclp-save-domain-btn', this.saveLicenseDomain.bind(this)); + $(document).on('click', '.wclp-cancel-domain-btn', this.cancelEditDomain); + + // Enter key on domain inputs + $('#wclp-order-domain').on('keypress', function(e) { + if (e.which === 13) { + e.preventDefault(); + $('#wclp-save-order-domain').click(); + } + }); + + $(document).on('keypress', '.wclp-domain-input', function(e) { + if (e.which === 13) { + e.preventDefault(); + $(this).closest('.wclp-license-domain-edit').find('.wclp-save-domain-btn').click(); + } + }); + }, + + /** + * Save order domain + */ + saveOrderDomain: function(e) { + e.preventDefault(); + + var $btn = $('#wclp-save-order-domain'); + var $input = $('#wclp-order-domain'); + var $spinner = $btn.siblings('.spinner'); + var $message = $btn.siblings('.wclp-status-message'); + + var orderId = $input.data('order-id'); + var domain = $input.val().trim(); + + $btn.prop('disabled', true); + $spinner.addClass('is-active'); + $message.text('').removeClass('success error'); + + $.ajax({ + url: wclpOrderLicenses.ajaxUrl, + type: 'POST', + data: { + action: 'wclp_update_order_domain', + nonce: wclpOrderLicenses.nonce, + order_id: orderId, + domain: domain + }, + success: function(response) { + if (response.success) { + $input.val(response.data.domain); + $message.text(wclpOrderLicenses.strings.saved).addClass('success'); + } else { + $message.text(response.data.message || wclpOrderLicenses.strings.error).addClass('error'); + } + }, + error: function() { + $message.text(wclpOrderLicenses.strings.error).addClass('error'); + }, + complete: function() { + $btn.prop('disabled', false); + $spinner.removeClass('is-active'); + + // Clear message after 3 seconds + setTimeout(function() { + $message.text('').removeClass('success error'); + }, 3000); + } + }); + }, + + /** + * Start editing license domain + */ + startEditDomain: function(e) { + e.preventDefault(); + + var $container = $(this).closest('.wclp-license-domain-edit'); + var $display = $container.find('.wclp-domain-display'); + var $input = $container.find('.wclp-domain-input'); + var $editBtn = $container.find('.wclp-edit-domain-btn'); + var $saveBtn = $container.find('.wclp-save-domain-btn'); + var $cancelBtn = $container.find('.wclp-cancel-domain-btn'); + + // Store original value + $input.data('original', $display.text()); + + // Toggle visibility + $display.hide(); + $editBtn.hide(); + $input.show().focus().select(); + $saveBtn.show(); + $cancelBtn.show(); + }, + + /** + * Cancel editing license domain + */ + cancelEditDomain: function(e) { + e.preventDefault(); + + var $container = $(this).closest('.wclp-license-domain-edit'); + var $display = $container.find('.wclp-domain-display'); + var $input = $container.find('.wclp-domain-input'); + var $editBtn = $container.find('.wclp-edit-domain-btn'); + var $saveBtn = $container.find('.wclp-save-domain-btn'); + var $cancelBtn = $container.find('.wclp-cancel-domain-btn'); + + // Restore original value + $input.val($input.data('original')); + + // Toggle visibility + $input.hide(); + $saveBtn.hide(); + $cancelBtn.hide(); + $display.show(); + $editBtn.show(); + }, + + /** + * Save license domain + */ + saveLicenseDomain: function(e) { + e.preventDefault(); + + var $btn = $(e.currentTarget); + var $container = $btn.closest('.wclp-license-domain-edit'); + var $row = $btn.closest('tr'); + var $input = $container.find('.wclp-domain-input'); + var $display = $container.find('.wclp-domain-display'); + var $spinner = $container.find('.spinner'); + var $editBtn = $container.find('.wclp-edit-domain-btn'); + var $cancelBtn = $container.find('.wclp-cancel-domain-btn'); + + var licenseId = $row.data('license-id'); + var domain = $input.val().trim(); + + if (!domain) { + alert(wclpOrderLicenses.strings.invalidDomain); + return; + } + + $btn.prop('disabled', true); + $cancelBtn.prop('disabled', true); + $spinner.addClass('is-active'); + + $.ajax({ + url: wclpOrderLicenses.ajaxUrl, + type: 'POST', + data: { + action: 'wclp_update_license_domain', + nonce: wclpOrderLicenses.nonce, + license_id: licenseId, + domain: domain + }, + success: function(response) { + if (response.success) { + // Update display + $display.text(response.data.domain); + $input.val(response.data.domain); + + // Switch back to display mode + $input.hide(); + $btn.hide(); + $cancelBtn.hide(); + $display.show(); + $editBtn.show(); + } else { + alert(response.data.message || wclpOrderLicenses.strings.error); + } + }, + error: function() { + alert(wclpOrderLicenses.strings.error); + }, + complete: function() { + $btn.prop('disabled', false); + $cancelBtn.prop('disabled', false); + $spinner.removeClass('is-active'); + } + }); + } + }; + + $(document).ready(function() { + WCLPOrderLicenses.init(); + }); + +})(jQuery); diff --git a/src/Admin/OrderLicenseController.php b/src/Admin/OrderLicenseController.php new file mode 100644 index 0000000..829d999 --- /dev/null +++ b/src/Admin/OrderLicenseController.php @@ -0,0 +1,395 @@ +licenseManager = $licenseManager; + $this->registerHooks(); + } + + /** + * Register WordPress hooks + */ + private function registerHooks(): void + { + // Add licenses meta box to order edit page + add_action('add_meta_boxes', [$this, 'addLicensesMetaBox']); + + // Handle AJAX actions + add_action('wp_ajax_wclp_update_order_domain', [$this, 'ajaxUpdateOrderDomain']); + add_action('wp_ajax_wclp_update_license_domain', [$this, 'ajaxUpdateLicenseDomain']); + + // Enqueue admin scripts + add_action('admin_enqueue_scripts', [$this, 'enqueueScripts']); + } + + /** + * Add licenses meta box to order edit page + */ + public function addLicensesMetaBox(): void + { + // Support both classic post type and HPOS + $screen = wc_get_container()->get(\Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController::class)->custom_orders_table_usage_is_enabled() + ? wc_get_page_screen_id('shop-order') + : 'shop_order'; + + add_meta_box( + 'wclp_order_licenses', + __('Product Licenses', 'wc-licensed-product'), + [$this, 'renderLicensesMetaBox'], + $screen, + 'normal', + 'default' + ); + } + + /** + * Render licenses meta box + */ + public function renderLicensesMetaBox($post_or_order): void + { + // Get order object - support both classic and HPOS + if ($post_or_order instanceof \WC_Order) { + $order = $post_or_order; + } else { + $order = wc_get_order($post_or_order->ID); + } + + if (!$order) { + echo '

' . esc_html__('Order not found.', 'wc-licensed-product') . '

'; + return; + } + + // Check if order has licensed products + $hasLicensedProduct = false; + foreach ($order->get_items() as $item) { + $product = $item->get_product(); + if ($product && $product->is_type('licensed')) { + $hasLicensedProduct = true; + break; + } + } + + if (!$hasLicensedProduct) { + echo '

' . esc_html__('This order does not contain licensed products.', 'wc-licensed-product') . '

'; + return; + } + + // Get order domain + $orderDomain = $order->get_meta('_licensed_product_domain'); + + // Get licenses for this order + $licenses = $this->licenseManager->getLicensesByOrder($order->get_id()); + + wp_nonce_field('wclp_order_license_actions', 'wclp_order_license_nonce'); + ?> +
+
+

+

+ +

+
+ + + + +
+
+ +
+ +

+ + +

+ + is_paid()): ?> +
+ + +
+ + +

+ + + + + + + + + + + + + + + getProductId()); + $statusClass = 'wclp-status-' . $license->getStatus(); + ?> + + + + + + + + + + +
+ getLicenseKey()); ?> + + + + get_name()); ?> + + + + + +
+ getDomain()); ?> + + + + + +
+
+ + getStatus())); ?> + + + getExpiresAt(); + if ($expiresAt) { + echo esc_html($expiresAt->format(get_option('date_format'))); + } else { + echo '' . esc_html__('Lifetime', 'wc-licensed-product') . ''; + } + ?> + + + + +
+ +

+ ' . esc_html__('Licenses', 'wc-licensed-product') . '' + ); + ?> +

+ +
+ + + id, ['shop_order', 'woocommerce_page_wc-orders'], true) + || (isset($_GET['page']) && $_GET['page'] === 'wc-orders' && isset($_GET['action']) && $_GET['action'] === 'edit'); + + if (!$isOrderEdit) { + return; + } + + wp_enqueue_script( + 'wclp-order-licenses', + WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/js/order-licenses.js', + ['jquery'], + WC_LICENSED_PRODUCT_VERSION, + true + ); + + wp_localize_script('wclp-order-licenses', 'wclpOrderLicenses', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('wclp_order_license_actions'), + 'strings' => [ + 'saving' => __('Saving...', 'wc-licensed-product'), + 'saved' => __('Saved!', 'wc-licensed-product'), + 'error' => __('Error saving. Please try again.', 'wc-licensed-product'), + 'invalidDomain' => __('Please enter a valid domain.', 'wc-licensed-product'), + ], + ]); + } + + /** + * AJAX handler for updating order domain + */ + public function ajaxUpdateOrderDomain(): void + { + check_ajax_referer('wclp_order_license_actions', 'nonce'); + + if (!current_user_can('manage_woocommerce')) { + wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]); + } + + $orderId = absint($_POST['order_id'] ?? 0); + $domain = sanitize_text_field($_POST['domain'] ?? ''); + + if (!$orderId) { + wp_send_json_error(['message' => __('Invalid order ID.', 'wc-licensed-product')]); + } + + $order = wc_get_order($orderId); + if (!$order) { + wp_send_json_error(['message' => __('Order not found.', 'wc-licensed-product')]); + } + + // Normalize and validate domain + $normalizedDomain = $this->licenseManager->normalizeDomain($domain); + if (!empty($domain) && !$this->isValidDomain($normalizedDomain)) { + wp_send_json_error(['message' => __('Invalid domain format.', 'wc-licensed-product')]); + } + + // Update order meta + $order->update_meta_data('_licensed_product_domain', $normalizedDomain); + $order->save(); + + wp_send_json_success([ + 'message' => __('Order domain updated.', 'wc-licensed-product'), + 'domain' => $normalizedDomain, + ]); + } + + /** + * AJAX handler for updating license domain + */ + public function ajaxUpdateLicenseDomain(): void + { + check_ajax_referer('wclp_order_license_actions', 'nonce'); + + if (!current_user_can('manage_woocommerce')) { + wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]); + } + + $licenseId = absint($_POST['license_id'] ?? 0); + $domain = sanitize_text_field($_POST['domain'] ?? ''); + + if (!$licenseId) { + wp_send_json_error(['message' => __('Invalid license ID.', 'wc-licensed-product')]); + } + + if (empty($domain)) { + wp_send_json_error(['message' => __('Domain cannot be empty.', 'wc-licensed-product')]); + } + + // Normalize and validate domain + $normalizedDomain = $this->licenseManager->normalizeDomain($domain); + if (!$this->isValidDomain($normalizedDomain)) { + wp_send_json_error(['message' => __('Invalid domain format.', 'wc-licensed-product')]); + } + + // Get license to verify it exists + $license = $this->licenseManager->getLicenseById($licenseId); + if (!$license) { + wp_send_json_error(['message' => __('License not found.', 'wc-licensed-product')]); + } + + // Update license domain + $success = $this->licenseManager->updateLicenseDomain($licenseId, $normalizedDomain); + + if ($success) { + wp_send_json_success([ + 'message' => __('License domain updated.', 'wc-licensed-product'), + 'domain' => $normalizedDomain, + ]); + } else { + wp_send_json_error(['message' => __('Failed to update license domain.', 'wc-licensed-product')]); + } + } + + /** + * Validate domain format + */ + private function isValidDomain(string $domain): bool + { + if (empty($domain)) { + return true; // Empty is allowed for order domain + } + + if (strlen($domain) > 255) { + return false; + } + + $pattern = '/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'; + return (bool) preg_match($pattern, $domain); + } +} diff --git a/src/License/LicenseManager.php b/src/License/LicenseManager.php index 2dea398..c06b798 100644 --- a/src/License/LicenseManager.php +++ b/src/License/LicenseManager.php @@ -161,6 +161,25 @@ class LicenseManager return $row ? License::fromArray($row) : null; } + /** + * Get all licenses for an order + */ + public function getLicensesByOrder(int $orderId): array + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$tableName} WHERE order_id = %d ORDER BY created_at DESC", + $orderId + ), + ARRAY_A + ); + + return array_map(fn(array $row) => License::fromArray($row), $rows ?: []); + } + /** * Get all licenses for a customer */ diff --git a/src/Plugin.php b/src/Plugin.php index 35adf39..18796bb 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace Jeremias\WcLicensedProduct; use Jeremias\WcLicensedProduct\Admin\AdminController; +use Jeremias\WcLicensedProduct\Admin\OrderLicenseController; use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController; use Jeremias\WcLicensedProduct\Api\RestApiController; @@ -122,6 +123,7 @@ final class Plugin if (is_admin()) { new AdminController($this->twig, $this->licenseManager); new VersionAdminController($this->versionManager); + new OrderLicenseController($this->licenseManager); new SettingsController(); } } diff --git a/wc-licensed-product.php b/wc-licensed-product.php index 8463b2d..bb71cef 100644 --- a/wc-licensed-product.php +++ b/wc-licensed-product.php @@ -3,7 +3,7 @@ * Plugin Name: WooCommerce Licensed Product * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product * Description: WooCommerce plugin to sell software products using license keys with domain-based validation. - * Version: 0.0.9 + * Version: 0.0.10 * Author: Marco Graetsch * Author URI: https://src.bundespruefstelle.ch/magdev * License: GPL-2.0-or-later @@ -28,7 +28,7 @@ if (!defined('ABSPATH')) { } // Plugin constants -define('WC_LICENSED_PRODUCT_VERSION', '0.0.9'); +define('WC_LICENSED_PRODUCT_VERSION', '0.0.10'); define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__); define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));