Implement version 0.0.10 features

- Add license meta box on WooCommerce order edit pages
- Add editable order domain field with AJAX inline editing
- Add editable license domains directly from order page
- Add licenses table showing all licenses for an order
- Support both classic orders and HPOS

New files:
- src/Admin/OrderLicenseController.php
- assets/js/order-licenses.js

New method: LicenseManager::getLicensesByOrder()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 22:18:27 +01:00
parent c4b1df8a22
commit f22ae95d6e
7 changed files with 710 additions and 5 deletions

View File

@@ -0,0 +1,395 @@
<?php
/**
* Order License Admin Controller
*
* @package Jeremias\WcLicensedProduct\Admin
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Admin;
use Jeremias\WcLicensedProduct\License\License;
use Jeremias\WcLicensedProduct\License\LicenseManager;
/**
* Handles license display and editing on order admin pages
*/
final class OrderLicenseController
{
private LicenseManager $licenseManager;
public function __construct(LicenseManager $licenseManager)
{
$this->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 '<p>' . esc_html__('Order not found.', 'wc-licensed-product') . '</p>';
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 '<p>' . esc_html__('This order does not contain licensed products.', 'wc-licensed-product') . '</p>';
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');
?>
<div class="wclp-order-licenses">
<div class="wclp-order-domain-section">
<h4><?php esc_html_e('Order Domain', 'wc-licensed-product'); ?></h4>
<p class="description">
<?php esc_html_e('The domain specified during checkout. Changing this will not automatically update existing license domains.', 'wc-licensed-product'); ?>
</p>
<div class="wclp-inline-edit">
<input type="text"
id="wclp-order-domain"
class="regular-text"
value="<?php echo esc_attr($orderDomain); ?>"
data-order-id="<?php echo esc_attr($order->get_id()); ?>"
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>" />
<button type="button" class="button" id="wclp-save-order-domain">
<?php esc_html_e('Save', 'wc-licensed-product'); ?>
</button>
<span class="spinner"></span>
<span class="wclp-status-message"></span>
</div>
</div>
<hr />
<h4><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h4>
<?php if (empty($licenses)): ?>
<p class="description">
<?php esc_html_e('No licenses have been generated for this order yet.', 'wc-licensed-product'); ?>
<?php if ($order->is_paid()): ?>
<br />
<em><?php esc_html_e('Licenses should be generated automatically when an order is paid. If missing, check that a domain was specified during checkout.', 'wc-licensed-product'); ?></em>
<?php else: ?>
<br />
<em><?php esc_html_e('Licenses will be generated when the order is marked as paid/completed.', 'wc-licensed-product'); ?></em>
<?php endif; ?>
</p>
<?php else: ?>
<table class="widefat striped wclp-licenses-table">
<thead>
<tr>
<th><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Product', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Status', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
<th><?php esc_html_e('Actions', 'wc-licensed-product'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($licenses as $license): ?>
<?php
$product = wc_get_product($license->getProductId());
$statusClass = 'wclp-status-' . $license->getStatus();
?>
<tr data-license-id="<?php echo esc_attr($license->getId()); ?>">
<td>
<code class="wclp-license-key"><?php echo esc_html($license->getLicenseKey()); ?></code>
</td>
<td>
<?php if ($product): ?>
<a href="<?php echo esc_url(get_edit_post_link($product->get_id())); ?>">
<?php echo esc_html($product->get_name()); ?>
</a>
<?php else: ?>
<?php esc_html_e('Unknown', 'wc-licensed-product'); ?>
<?php endif; ?>
</td>
<td>
<div class="wclp-license-domain-edit">
<span class="wclp-domain-display"><?php echo esc_html($license->getDomain()); ?></span>
<input type="text"
class="wclp-domain-input regular-text"
value="<?php echo esc_attr($license->getDomain()); ?>"
style="display: none;" />
<button type="button" class="button-link wclp-edit-domain-btn" title="<?php esc_attr_e('Edit domain', 'wc-licensed-product'); ?>">
<span class="dashicons dashicons-edit"></span>
</button>
<button type="button" class="button button-small wclp-save-domain-btn" style="display: none;">
<?php esc_html_e('Save', 'wc-licensed-product'); ?>
</button>
<button type="button" class="button-link wclp-cancel-domain-btn" style="display: none;">
<?php esc_html_e('Cancel', 'wc-licensed-product'); ?>
</button>
<span class="spinner"></span>
</div>
</td>
<td>
<span class="wclp-license-status <?php echo esc_attr($statusClass); ?>">
<?php echo esc_html(ucfirst($license->getStatus())); ?>
</span>
</td>
<td>
<?php
$expiresAt = $license->getExpiresAt();
if ($expiresAt) {
echo esc_html($expiresAt->format(get_option('date_format')));
} else {
echo '<span class="wclp-lifetime">' . esc_html__('Lifetime', 'wc-licensed-product') . '</span>';
}
?>
</td>
<td>
<a href="<?php echo esc_url(admin_url('admin.php?page=wc-licenses&s=' . urlencode($license->getLicenseKey()))); ?>"
class="button button-small"
title="<?php esc_attr_e('View in Licenses', 'wc-licensed-product'); ?>">
<span class="dashicons dashicons-visibility" style="vertical-align: middle;"></span>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="description" style="margin-top: 10px;">
<?php
printf(
/* translators: %s: Link to licenses page */
esc_html__('For more actions (revoke, extend, delete), go to the %s page.', 'wc-licensed-product'),
'<a href="' . esc_url(admin_url('admin.php?page=wc-licenses')) . '">' . esc_html__('Licenses', 'wc-licensed-product') . '</a>'
);
?>
</p>
<?php endif; ?>
</div>
<style>
.wclp-order-licenses { padding: 10px 0; }
.wclp-order-domain-section { margin-bottom: 15px; }
.wclp-inline-edit { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.wclp-inline-edit .spinner { float: none; margin: 0; }
.wclp-status-message { font-style: italic; color: #666; }
.wclp-status-message.success { color: #46b450; }
.wclp-status-message.error { color: #dc3232; }
.wclp-licenses-table { margin-top: 10px; }
.wclp-licenses-table th, .wclp-licenses-table td { padding: 8px 10px; vertical-align: middle; }
.wclp-license-key { font-size: 12px; }
.wclp-license-domain-edit { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.wclp-license-domain-edit .spinner { float: none; margin: 0; }
.wclp-domain-input { max-width: 200px; }
.wclp-license-status { padding: 3px 8px; border-radius: 3px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.wclp-status-active { background: #d4edda; color: #155724; }
.wclp-status-inactive { background: #fff3cd; color: #856404; }
.wclp-status-expired { background: #f8d7da; color: #721c24; }
.wclp-status-revoked { background: #d6d8db; color: #383d41; }
.wclp-lifetime { color: #0073aa; font-weight: 500; }
.wclp-edit-domain-btn { color: #0073aa; text-decoration: none; }
.wclp-edit-domain-btn .dashicons { font-size: 16px; width: 16px; height: 16px; }
</style>
<?php
}
/**
* Enqueue admin scripts
*/
public function enqueueScripts(string $hook): void
{
// Check if we're on an order edit page
$screen = get_current_screen();
if (!$screen) {
return;
}
$isOrderEdit = in_array($screen->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);
}
}

View File

@@ -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
*/

View File

@@ -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();
}
}