diff --git a/CHANGELOG.md b/CHANGELOG.md index 83c500b..ff5af85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.3.5] - 2026-01-23 +### Added + +- Admin dashboard widget showing license statistics on WordPress dashboard +- Automatic license expiration via daily wp-cron job +- License expired email notification sent when license auto-expires +- New `LicenseExpiredEmail` WooCommerce email class (configurable via WooCommerce > Settings > Emails) + ### Changed - Improved download list layout in customer account licenses page @@ -17,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Technical Details +- New `DashboardWidgetController` class in `src/Admin/` for WordPress dashboard widget +- Widget displays: total licenses, active, expiring soon, expired counts, status breakdown, license types +- New `LicenseExpiredEmail` class in `src/Email/` for expired license notifications +- Added `getExpiredActiveLicenses()` and `autoExpireLicense()` methods to `LicenseManager` +- Daily cron now auto-expires licenses with past expiration date and sends notification emails - Updated `templates/frontend/licenses.html.twig` with new two-row structure - Added `.download-item`, `.download-row-file`, `.download-row-meta` CSS classes - Improved responsive behavior for download metadata diff --git a/CLAUDE.md b/CLAUDE.md index ea699c8..1ae071b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,10 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w No known bugs at the moment. +### Version 0.4.0 + +- On first plugin activation, get the checksums of all security related files (at least in `src/`) as hashes, store them encrypted on the server and add a mechanism to check the integrity of the files and the license validity periodically, control via wp-cron. + ## Technical Stack - **Language:** PHP 8.3.x @@ -933,26 +937,40 @@ Added current version display on single product pages for licensed products. - Only displays if product has at least one version defined - Uses `LicensedProduct::get_current_version()` which queries `VersionManager::getLatestVersion()` -### 2026-01-23 - Version 0.3.5 - Download List UI Improvement +### 2026-01-23 - Version 0.3.5 - Dashboard Widget & Auto-Expire **Overview:** -Improved the download list layout in customer account licenses page with a two-row format. +Added admin dashboard widget for license statistics and automatic license expiration via daily cron job. **Implemented:** -- Downloads now displayed in two rows per entry -- First row: File download link -- Second row: Metadata (version, date, checksum) -- Better visual separation and readability +- Admin dashboard widget showing license statistics (total, active, expiring soon, expired) +- Status breakdown display with color-coded badges +- License type breakdown (time-limited vs lifetime) +- Daily wp-cron job to auto-expire licenses past their expiration date +- License expired email notification sent when license auto-expires +- Downloads in customer account now displayed in two-row format + +**New files:** + +- `src/Admin/DashboardWidgetController.php` - WordPress dashboard widget controller +- `src/Email/LicenseExpiredEmail.php` - WooCommerce email for expired license notifications **Modified files:** +- `src/Plugin.php` - Added DashboardWidgetController instantiation +- `src/License/LicenseManager.php` - Added `getExpiredActiveLicenses()` and `autoExpireLicense()` methods +- `src/Email/LicenseEmailController.php` - Added auto-expire logic and LicenseExpiredEmail registration - `templates/frontend/licenses.html.twig` - Restructured download list with two-row layout -- `assets/css/frontend.css` - Added `.download-item`, `.download-row-file`, `.download-row-meta` styles +- `assets/css/frontend.css` - Added dashboard widget and download list styles **Technical notes:** -- Changed `
  • ` from single-row flex to column flex layout -- Metadata row indented with left padding for visual hierarchy -- Updated responsive CSS for mobile devices +- Dashboard widget uses `wp_add_dashboard_widget()` hook, requires `manage_woocommerce` capability +- Widget displays statistics from existing `LicenseManager::getStatistics()` method +- Auto-expire runs during daily `wclp_check_expiring_licenses` cron event +- `getExpiredActiveLicenses()` finds licenses with past expiration date but still active status +- `autoExpireLicense()` updates status to expired and returns true if changed +- LicenseExpiredEmail follows same pattern as LicenseExpirationEmail (warning vs expired) +- Expired notification tracked via user meta to prevent duplicate emails diff --git a/src/Admin/DashboardWidgetController.php b/src/Admin/DashboardWidgetController.php new file mode 100644 index 0000000..fef22df --- /dev/null +++ b/src/Admin/DashboardWidgetController.php @@ -0,0 +1,225 @@ +licenseManager = $licenseManager; + $this->registerHooks(); + } + + /** + * Register WordPress hooks + */ + private function registerHooks(): void + { + add_action('wp_dashboard_setup', [$this, 'registerDashboardWidget']); + } + + /** + * Register the dashboard widget + */ + public function registerDashboardWidget(): void + { + if (!current_user_can('manage_woocommerce')) { + return; + } + + wp_add_dashboard_widget( + 'wclp_license_statistics', + __('License Statistics', 'wc-licensed-product'), + [$this, 'renderWidget'] + ); + } + + /** + * Render the dashboard widget content + */ + public function renderWidget(): void + { + $stats = $this->licenseManager->getStatistics(); + $licensesUrl = admin_url('admin.php?page=wc-licensed-product-licenses'); + ?> + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + +

    + +

    +
    + + + + + + + + + + + + + + + + +
    + +
    + +

    + +

    +

    + + +   |   + + +

    + + + autoExpireAndNotify(); + // Check if expiration emails are enabled in settings if (!SettingsController::isExpirationEmailsEnabled()) { return; @@ -107,6 +111,41 @@ final class LicenseEmailController } } + /** + * Auto-expire licenses and send expired notifications + */ + private function autoExpireAndNotify(): void + { + // Get licenses that should be auto-expired + $expiredActiveLicenses = $this->licenseManager->getExpiredActiveLicenses(); + + if (empty($expiredActiveLicenses)) { + return; + } + + // Get the WooCommerce email instance for expired notifications + $mailer = WC()->mailer(); + $emails = $mailer->get_emails(); + + /** @var LicenseExpiredEmail|null $expiredEmail */ + $expiredEmail = $emails['WCLP_License_Expired'] ?? null; + + foreach ($expiredActiveLicenses as $license) { + // Auto-expire the license + $wasExpired = $this->licenseManager->autoExpireLicense($license->getId()); + + if ($wasExpired && $expiredEmail && $expiredEmail->is_enabled()) { + // Check if we haven't already sent an expired notification + if (!$this->licenseManager->wasExpirationNotified($license->getId(), 'license_expired')) { + // Send expired notification email + if ($expiredEmail->trigger($license)) { + $this->licenseManager->markExpirationNotified($license->getId(), 'license_expired'); + } + } + } + } + } + /** * Process and send expiration warnings for a specific time frame * diff --git a/src/Email/LicenseExpiredEmail.php b/src/Email/LicenseExpiredEmail.php new file mode 100644 index 0000000..19298cc --- /dev/null +++ b/src/Email/LicenseExpiredEmail.php @@ -0,0 +1,335 @@ +id = 'wclp_license_expired'; + $this->customer_email = true; + $this->title = __('License Expired', 'wc-licensed-product'); + $this->description = __('License expired emails are sent to customers when their licenses have expired.', 'wc-licensed-product'); + + $this->placeholders = [ + '{site_title}' => $this->get_blogname(), + '{product_name}' => '', + '{expiration_date}' => '', + ]; + + // Call parent constructor + parent::__construct(); + } + + /** + * Get email subject + */ + public function get_default_subject(): string + { + return __('[{site_title}] Your license for {product_name} has expired', 'wc-licensed-product'); + } + + /** + * Get email heading + */ + public function get_default_heading(): string + { + return __('License Expired', 'wc-licensed-product'); + } + + /** + * Trigger the email + * + * @param License $license License object + */ + public function trigger(License $license): bool + { + $this->setup_locale(); + + $customer = get_userdata($license->getCustomerId()); + if (!$customer || !$customer->user_email) { + $this->restore_locale(); + return false; + } + + $this->license = $license; + $this->recipient = $customer->user_email; + $this->customer_name = $customer->display_name ?: __('Customer', 'wc-licensed-product'); + + $product = wc_get_product($license->getProductId()); + $this->product_name = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'); + + $expiresAt = $license->getExpiresAt(); + $this->expiration_date = $expiresAt ? $expiresAt->format(get_option('date_format')) : ''; + + // Update placeholders + $this->placeholders['{product_name}'] = $this->product_name; + $this->placeholders['{expiration_date}'] = $this->expiration_date; + + if (!$this->is_enabled() || !$this->get_recipient()) { + $this->restore_locale(); + return false; + } + + $result = $this->send( + $this->get_recipient(), + $this->get_subject(), + $this->get_content(), + $this->get_headers(), + $this->get_attachments() + ); + + $this->restore_locale(); + + return $result; + } + + /** + * Get content HTML + */ + public function get_content_html(): string + { + ob_start(); + + // Use WooCommerce's email header + wc_get_template('emails/email-header.php', ['email_heading' => $this->get_heading()]); + + $this->render_email_body_html(); + + // Use WooCommerce's email footer + wc_get_template('emails/email-footer.php', ['email' => $this]); + + return ob_get_clean(); + } + + /** + * Get content plain text + */ + public function get_content_plain(): string + { + ob_start(); + + echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n"; + echo esc_html(wp_strip_all_tags($this->get_heading())); + echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n"; + + $this->render_email_body_plain(); + + return ob_get_clean(); + } + + /** + * Render HTML email body content + */ + private function render_email_body_html(): void + { + $account_url = wc_get_account_endpoint_url('licenses'); + ?> +

    customer_name)); ?>

    + +

    + ' . esc_html($this->product_name) . '', + esc_html($this->expiration_date) + ); ?> +

    + +

    + +

    + +

    + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    product_name); ?>
    + + license->getLicenseKey()); ?> + +
    license->getDomain()); ?>
    expiration_date); ?>
    + + + +
    +
    + + get_additional_content(); + if ($additional_content) : + ?> +

    + + +

    + + + +

    + customer_name)); + echo "\n\n"; + + printf( + esc_html__('Your license for %1$s has expired on %2$s.', 'wc-licensed-product'), + esc_html($this->product_name), + esc_html($this->expiration_date) + ); + echo "\n\n"; + + echo esc_html__('Your license is no longer valid and the product will stop working until you renew.', 'wc-licensed-product'); + echo "\n\n"; + + echo "----------\n"; + echo esc_html__('Expired License Details', 'wc-licensed-product') . "\n"; + echo "----------\n\n"; + + echo esc_html__('Product:', 'wc-licensed-product') . ' ' . esc_html($this->product_name) . "\n"; + echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($this->license->getLicenseKey()) . "\n"; + echo esc_html__('Domain:', 'wc-licensed-product') . ' ' . esc_html($this->license->getDomain()) . "\n"; + echo esc_html__('Expired on:', 'wc-licensed-product') . ' ' . esc_html($this->expiration_date) . "\n"; + echo esc_html__('Status:', 'wc-licensed-product') . ' ' . esc_html__('Expired', 'wc-licensed-product') . "\n\n"; + + $additional_content = $this->get_additional_content(); + if ($additional_content) { + echo "----------\n\n"; + echo esc_html(wp_strip_all_tags(wptexturize($additional_content))); + echo "\n\n"; + } + + echo esc_html__('View My Licenses', 'wc-licensed-product') . ': ' . esc_url(wc_get_account_endpoint_url('licenses')) . "\n\n"; + + echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n"; + } + + /** + * Default content to show below main email content + */ + public function get_default_additional_content(): string + { + return __('To continue using this product, please renew your license.', 'wc-licensed-product'); + } + + /** + * Initialize settings form fields + */ + public function init_form_fields(): void + { + $placeholder_text = sprintf( + /* translators: %s: list of placeholders */ + __('Available placeholders: %s', 'wc-licensed-product'), + '{site_title}, {product_name}, {expiration_date}' + ); + + $this->form_fields = [ + 'enabled' => [ + 'title' => __('Enable/Disable', 'wc-licensed-product'), + 'type' => 'checkbox', + 'label' => __('Enable this email notification', 'wc-licensed-product'), + 'default' => 'yes', + ], + 'subject' => [ + 'title' => __('Subject', 'wc-licensed-product'), + 'type' => 'text', + 'desc_tip' => true, + 'description' => $placeholder_text, + 'placeholder' => $this->get_default_subject(), + 'default' => '', + ], + 'heading' => [ + 'title' => __('Email heading', 'wc-licensed-product'), + 'type' => 'text', + 'desc_tip' => true, + 'description' => $placeholder_text, + 'placeholder' => $this->get_default_heading(), + 'default' => '', + ], + 'additional_content' => [ + 'title' => __('Additional content', 'wc-licensed-product'), + 'description' => __('Text to appear below the main email content.', 'wc-licensed-product') . ' ' . $placeholder_text, + 'css' => 'width:400px; height: 75px;', + 'placeholder' => $this->get_default_additional_content(), + 'type' => 'textarea', + 'default' => '', + 'desc_tip' => true, + ], + 'email_type' => [ + 'title' => __('Email type', 'wc-licensed-product'), + 'type' => 'select', + 'description' => __('Choose which format of email to send.', 'wc-licensed-product'), + 'default' => 'html', + 'class' => 'email_type wc-enhanced-select', + 'options' => $this->get_email_type_options(), + 'desc_tip' => true, + ], + ]; + } +} diff --git a/src/License/LicenseManager.php b/src/License/LicenseManager.php index 4645e6a..e30125a 100644 --- a/src/License/LicenseManager.php +++ b/src/License/LicenseManager.php @@ -862,6 +862,56 @@ class LicenseManager return (bool) get_user_meta($license->getCustomerId(), $metaKey, true); } + /** + * Get licenses that have passed their expiration date but are still marked as active + * + * @return array Array of License objects that need to be auto-expired + */ + public function getExpiredActiveLicenses(): array + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $now = new \DateTimeImmutable(); + + $sql = "SELECT * FROM {$tableName} + WHERE expires_at IS NOT NULL + AND expires_at < %s + AND status = %s"; + + $rows = $wpdb->get_results( + $wpdb->prepare($sql, $now->format('Y-m-d H:i:s'), License::STATUS_ACTIVE), + ARRAY_A + ); + + return array_map(fn(array $row) => License::fromArray($row), $rows ?: []); + } + + /** + * Auto-expire a license and return true if status was changed + * + * @param int $licenseId License ID + * @return bool True if license was expired, false if already expired or error + */ + public function autoExpireLicense(int $licenseId): bool + { + $license = $this->getLicenseById($licenseId); + if (!$license) { + return false; + } + + // Only expire if currently active and past expiration date + if ($license->getStatus() !== License::STATUS_ACTIVE) { + return false; + } + + if (!$license->isExpired()) { + return false; + } + + return $this->updateLicenseStatus($licenseId, License::STATUS_EXPIRED); + } + /** * Import a license from CSV data * diff --git a/src/Plugin.php b/src/Plugin.php index a216045..398077e 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\DashboardWidgetController; use Jeremias\WcLicensedProduct\Admin\OrderLicenseController; use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController; @@ -151,6 +152,7 @@ final class Plugin new VersionAdminController($this->versionManager); new OrderLicenseController($this->licenseManager); new SettingsController(); + new DashboardWidgetController($this->licenseManager); // Show admin notice if unlicensed and not on localhost if (!$isLicensed && !$licenseChecker->isLocalhost()) {