Implement versions 0.0.4-0.0.7 features

v0.0.4:
- Add WooCommerce settings tab for default license settings
- Per-product settings override global defaults

v0.0.5:
- Add bulk license operations (activate, deactivate, revoke, extend, delete)
- Add license renewal/extension and lifetime functionality
- Add quick action buttons per license row

v0.0.6:
- Add license dashboard with statistics and analytics
- Add license transfer functionality (admin)
- Add CSV export for licenses
- Add OpenAPI 3.1 specification
- Remove /deactivate API endpoint

v0.0.7:
- Move license dashboard to WooCommerce Reports section
- Add license search and filtering in admin
- Add customer-facing license transfer with AJAX modal
- Add email notifications for license expiration warnings
- Add bulk import licenses from CSV
- Update README with comprehensive documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 20:32:35 +01:00
parent 78e43b9aea
commit 49a0699963
21 changed files with 4132 additions and 289 deletions

View File

@@ -7,6 +7,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.0.7] - 2026-01-21
### Added
- License Dashboard moved to WooCommerce Reports section (Reports > Licenses)
- License search and filtering in admin (by license key, domain, status, product)
- Customer-facing license transfer request with AJAX-based modal
- Email notifications for license expiration warnings (7 days and 1 day before)
- Bulk import licenses from CSV functionality
- Import page with detailed format instructions
- Scheduled cron job for daily expiration checks
### Changed
- Dashboard now accessible via WooCommerce > Reports > Licenses tab
- License list page includes search box and filter dropdowns
- Pagination preserves filter state
- Import CSV button added to licenses page header
### Technical Details
- AccountController extended with customer transfer AJAX handler and domain normalization
- LicenseEmailController extended with expiration warning scheduling and email templates
- LicenseManager extended with `getLicensesExpiringSoon()`, `markExpirationNotified()`, `wasExpirationNotified()`, `importLicense()` methods
- AdminController extended with CSV import handling and import page rendering
- Installer clears cron events on plugin deactivation
- Frontend JavaScript extended for transfer modal handling
- Frontend CSS extended with modal and transfer button styles
## [0.0.6] - 2026-01-21
### Added
- License usage statistics/analytics dashboard (WooCommerce > License Dashboard)
- License transfer functionality to change domain from admin
- Export licenses to CSV functionality
- OpenAPI 3.1 specification for REST API documentation (`openapi.json`)
- Monthly license creation chart on dashboard
- Top products and top domains statistics
- Expiring soon alerts on dashboard
### Removed
- API endpoint `/deactivate` removed (license deactivation is now admin-only)
### Changed
- Licenses admin page header now includes Export CSV button
- Actions column widened to accommodate Transfer link
- Dashboard link added to licenses page
### Technical Details
- New admin page: License Dashboard with statistics overview
- LicenseManager extended with `transferLicense()`, `getStatistics()`, `exportLicensesForCsv()` methods
- AdminController extended with dashboard rendering, CSV export, and transfer handling
- Transfer modal with form for domain change
- REST API now only has three endpoints: `/validate`, `/status`, `/activate`
- OpenAPI 3.1 specification documents all API endpoints with examples
## [0.0.5] - 2026-01-21
### Added
- Bulk license operations in admin (activate, deactivate, revoke, extend, delete)
- License renewal/extension functionality (extend by 30/90/365 days)
- Set license to lifetime (remove expiration)
- Quick action buttons per license row (+30d, ∞, Revoke, Delete)
- Checkbox selection for bulk operations with select-all functionality
- Improved admin notices for bulk operation results
### Changed
- Licenses admin page redesigned with WordPress list table styling
- License actions use row-actions pattern for cleaner UI
- Bulk action dropdowns at top and bottom of table (synchronized)
### Technical Details
- LicenseManager extended with `extendLicense()`, `setLicenseLifetime()`, `bulkUpdateStatus()`, `bulkDelete()`, `bulkExtend()` methods
- AdminController extended with bulk action handling and extend/lifetime actions
- Twig template functions for generating action URLs with nonces
- JavaScript for checkbox sync and bulk action handling
## [0.0.4] - 2026-01-21
### Added
- WooCommerce settings tab "Licensed Products" for default license settings
- Default settings for Max Activations, License Validity, and Bind to Major Version
- Per-product settings now override global defaults (empty = use default)
- Settings link in product edit page pointing to WooCommerce settings
### Changed
- Product license settings now show placeholder with default values
- LicensedProduct class now falls back to global defaults when product settings are empty
### Technical Details
- New class: SettingsController for WooCommerce settings integration
- LicensedProduct model extended with `has_custom_*` methods for checking overrides
- Settings stored using WooCommerce options API
## [0.0.3] - 2026-01-21 ## [0.0.3] - 2026-01-21
### Added ### Added
@@ -67,7 +171,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `POST /wp-json/wc-licensed-product/v1/validate` - Validate license for domain - `POST /wp-json/wc-licensed-product/v1/validate` - Validate license for domain
- `POST /wp-json/wc-licensed-product/v1/status` - Check license status - `POST /wp-json/wc-licensed-product/v1/status` - Check license status
- `POST /wp-json/wc-licensed-product/v1/activate` - Activate license on domain - `POST /wp-json/wc-licensed-product/v1/activate` - Activate license on domain
- `POST /wp-json/wc-licensed-product/v1/deactivate` - Deactivate license
- Checkout domain field for licensed products - Checkout domain field for licensed products
- Customer account page "Licenses" to view purchased licenses - Customer account page "Licenses" to view purchased licenses
- Admin interface for license management (WooCommerce > Licenses) - Admin interface for license management (WooCommerce > Licenses)
@@ -92,7 +195,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- WordPress REST API integration - WordPress REST API integration
- Custom WooCommerce product type extending WC_Product - Custom WooCommerce product type extending WC_Product
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.3...HEAD [Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.7...HEAD
[0.0.7]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.6...v0.0.7
[0.0.6]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.5...v0.0.6
[0.0.5]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.4...v0.0.5
[0.0.4]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.3...v0.0.4
[0.0.3]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.2...v0.0.3 [0.0.3]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.2...v0.0.3
[0.0.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.1...v0.0.2 [0.0.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.1...v0.0.2
[0.0.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/releases/tag/v0.0.1 [0.0.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/releases/tag/v0.0.1

125
CLAUDE.md
View File

@@ -36,11 +36,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
_No known bugs at this time._ _No known bugs at this time._
### Version 0.0.4 (Next) ### Version 0.0.8 (Next)
- Consider adding license usage statistics/analytics _No planned features yet. Add items as needed._
- Consider adding bulk license operations in admin
- Consider adding license renewal/extension functionality
## Technical Stack ## Technical Stack
@@ -226,11 +224,12 @@ Created on plugin activation via `Installer::createTables()`:
Base: `/wp-json/wc-licensed-product/v1/` Base: `/wp-json/wc-licensed-product/v1/`
| Endpoint | Method | Description | | Endpoint | Method | Description |
| -------------- | ------ | -------------------------------- | | ----------- | ------ | ------------------------------- |
| `/validate` | POST | Validate license key for domain | | `/validate` | POST | Validate license key for domain |
| `/status` | POST | Get license status | | `/status` | POST | Get license status |
| `/activate` | POST | Activate license on domain | | `/activate` | POST | Activate license on domain |
| `/deactivate` | POST | Deactivate license |
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
### Key Classes ### Key Classes
@@ -316,3 +315,117 @@ Base: `/wp-json/wc-licensed-product/v1/`
**Bug fixes:** **Bug fixes:**
- Fixed product versions meta box visibility for non-licensed product types (targets `#wc_licensed_product_versions` container) - Fixed product versions meta box visibility for non-licensed product types (targets `#wc_licensed_product_versions` container)
### 2026-01-21 - Version 0.0.4 Features
**Implemented:**
- WooCommerce settings tab "Licensed Products" for default license settings
- Default settings for Max Activations, License Validity, and Bind to Major Version
- Per-product settings override global defaults (empty = use default)
- Settings link in product edit page pointing to WooCommerce settings
**New classes:**
- `SettingsController` - WooCommerce settings tab integration
**Technical notes:**
- Settings stored using WooCommerce options API
- LicensedProduct model falls back to global defaults when product settings are empty
- Added `has_custom_*` methods to check if product has overridden defaults
- Product edit panel shows placeholders with default values
### 2026-01-21 - Version 0.0.5 Features
**Implemented:**
- Bulk license operations in admin (activate, deactivate, revoke, extend, delete)
- License renewal/extension functionality (extend by 30/90/365 days)
- Set license to lifetime (remove expiration)
- Quick action buttons per license row (+30d, ∞, Revoke, Delete)
- Checkbox selection with select-all for bulk operations
- Improved admin notices for bulk operation results
**New methods in LicenseManager:**
- `extendLicense()` - Extend license by specified days
- `setLicenseLifetime()` - Remove expiration (set to lifetime)
- `bulkUpdateStatus()` - Bulk update license status
- `bulkDelete()` - Bulk delete licenses
- `bulkExtend()` - Bulk extend licenses
**Technical notes:**
- Admin page redesigned with WordPress list table styling
- Twig template functions for generating action URLs with nonces
- JavaScript for checkbox synchronization and bulk action handling
- Row-actions pattern for cleaner per-license action UI
### 2026-01-21 - Version 0.0.6 Features
**Implemented:**
- License usage statistics/analytics dashboard (WooCommerce > License Dashboard)
- License transfer functionality (change domain from admin)
- Export licenses to CSV with all related data
- OpenAPI 3.1 specification for REST API (`openapi.json`)
- Removed `/deactivate` API endpoint (admin-only operation now)
**New methods in LicenseManager:**
- `transferLicense()` - Transfer license to new domain
- `getStatistics()` - Get comprehensive license statistics
- `exportLicensesForCsv()` - Export all licenses for CSV download
**New files:**
- `templates/admin/dashboard.html.twig` - Dashboard template
- `openapi.json` - OpenAPI 3.1 specification
**Technical notes:**
- Dashboard shows status cards, monthly chart, top products/domains
- Transfer uses modal dialog with form submission
- CSV export includes BOM for UTF-8 Excel compatibility
- OpenAPI spec documents all endpoints with examples and error responses
- Statistics query uses SQL aggregation for performance
### 2026-01-21 - Version 0.0.7 Features
**Implemented:**
- License Dashboard moved to WooCommerce Reports section (Reports > Licenses tab)
- License search and filtering in admin (by key, domain, status, product)
- Customer-facing license transfer request with AJAX modal
- Email notifications for license expiration warnings (7 days and 1 day before)
- Bulk import licenses from CSV with validation and options
**New methods in LicenseManager:**
- `getLicensesExpiringSoon()` - Get licenses expiring within specified days
- `markExpirationNotified()` - Track expiration notification state
- `wasExpirationNotified()` - Check if notification already sent
- `importLicense()` - Create license from imported data
**Updated controllers:**
- `AdminController` - Added Reports integration, search/filter handling, CSV import
- `AccountController` - Added customer transfer AJAX handler with domain validation
- `LicenseEmailController` - Added cron scheduling and expiration warning emails
- `Installer` - Clears cron events on deactivation
**New UI features:**
- Search box and filter dropdowns on licenses page
- Transfer button and modal on customer licenses page
- Import CSV page with format documentation
- Pagination preserves filter state
**Technical notes:**
- WooCommerce Reports integration uses `woocommerce_admin_reports` filter
- Customer transfer uses AJAX with nonce verification
- Expiration warnings use WordPress cron with daily schedule
- CSV import supports both exported format and simplified format
- User meta tracks expiration notifications to prevent duplicates

133
README.md
View File

@@ -8,14 +8,34 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
## Features ## Features
### Core Features
- **Licensed Product Type**: New WooCommerce product type for software sales - **Licensed Product Type**: New WooCommerce product type for software sales
- **Automatic License Generation**: License keys generated on order completion - **Automatic License Generation**: License keys generated on order completion (format: XXXX-XXXX-XXXX-XXXX)
- **Domain Binding**: Licenses are bound to customer-specified domains - **Domain Binding**: Licenses are bound to customer-specified domains
- **REST API**: Public endpoints for license validation and management - **REST API**: Public endpoints for license validation and management
- **Customer Account**: Customers can view their licenses in My Account
- **Admin Management**: Full CRUD interface for license management
- **Version Binding**: Optional binding to major software versions - **Version Binding**: Optional binding to major software versions
- **Expiration Support**: Set license validity periods or lifetime licenses - **Expiration Support**: Set license validity periods or lifetime licenses
- **Rate Limiting**: API endpoints protected with rate limiting (30 requests/minute)
### Customer Features
- **My Account Licenses**: Customers can view their licenses in My Account
- **License Transfers**: Customers can transfer licenses to new domains
- **Secure Downloads**: Download purchased software versions with license verification
- **Copy to Clipboard**: Easy license key copying
### Admin Features
- **License Management**: Full CRUD interface for license management
- **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses)
- **Search & Filtering**: Search by license key, domain, status, or product
- **Bulk Operations**: Activate, deactivate, revoke, extend, or delete multiple licenses
- **License Transfer**: Transfer licenses to new domains
- **CSV Export/Import**: Export and import licenses via CSV
- **Expiration Warnings**: Automatic email notifications before license expiration
- **Version Management**: Manage multiple versions per product with file attachments
- **Global Settings**: Default license settings via WooCommerce settings tab
## Requirements ## Requirements
@@ -42,6 +62,19 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
- **Bind to Major Version**: Lock license to current major version - **Bind to Major Version**: Lock license to current major version
- **Current Version**: Your software's current version - **Current Version**: Your software's current version
### Managing Product Versions
1. Edit a Licensed Product
2. Use the "Product Versions" meta box to add versions
3. Upload version files via WordPress Media Library
4. Version numbers are auto-detected from filenames (e.g., `plugin-v1.2.3.zip`)
### Global Default Settings
1. Go to WooCommerce > Settings > Licensed Products
2. Set default values for Max Activations, License Validity, and Version Binding
3. Per-product settings override these defaults
### Customer Checkout ### Customer Checkout
When a customer purchases a licensed product, they must enter the domain where they will use the license during checkout. When a customer purchases a licensed product, they must enter the domain where they will use the license during checkout.
@@ -50,11 +83,30 @@ When a customer purchases a licensed product, they must enter the domain where t
- **Customers**: My Account > Licenses - **Customers**: My Account > Licenses
- **Administrators**: WooCommerce > Licenses - **Administrators**: WooCommerce > Licenses
- **Dashboard**: WooCommerce > Reports > Licenses (for statistics)
### Exporting & Importing Licenses
**Export:**
1. Go to WooCommerce > Licenses
2. Click "Export CSV" to download all licenses
**Import:**
1. Go to WooCommerce > Licenses
2. Click "Import CSV"
3. Upload a CSV file (supports exported format or simplified format)
4. Choose options: skip header row, update existing licenses
## REST API ## REST API
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
### Validate License ### Validate License
Validate a license key for a specific domain.
```http ```http
POST /wp-json/wc-licensed-product/v1/validate POST /wp-json/wc-licensed-product/v1/validate
Content-Type: application/json Content-Type: application/json
@@ -65,8 +117,33 @@ Content-Type: application/json
} }
``` ```
**Success Response (200):**
```json
{
"valid": true,
"license": {
"product_id": 123,
"expires_at": "2027-01-21",
"version_id": 5
}
}
```
**Error Response (403):**
```json
{
"valid": false,
"error": "domain_mismatch",
"message": "This license is not valid for this domain."
}
```
### Check Status ### Check Status
Get detailed license status information.
```http ```http
POST /wp-json/wc-licensed-product/v1/status POST /wp-json/wc-licensed-product/v1/status
Content-Type: application/json Content-Type: application/json
@@ -76,8 +153,23 @@ Content-Type: application/json
} }
``` ```
**Response (200):**
```json
{
"valid": true,
"status": "active",
"domain": "example.com",
"expires_at": "2027-01-21",
"activations_count": 1,
"max_activations": 3
}
```
### Activate License ### Activate License
Activate a license on a domain.
```http ```http
POST /wp-json/wc-licensed-product/v1/activate POST /wp-json/wc-licensed-product/v1/activate
Content-Type: application/json Content-Type: application/json
@@ -88,18 +180,27 @@ Content-Type: application/json
} }
``` ```
### Deactivate License **Response (200):**
```http
POST /wp-json/wc-licensed-product/v1/deactivate
Content-Type: application/json
```json
{ {
"license_key": "XXXX-XXXX-XXXX-XXXX", "success": true,
"domain": "example.com" "message": "License activated successfully."
} }
``` ```
### Error Codes
| Code | Description |
| ---- | ----------- |
| `license_not_found` | License key does not exist |
| `license_revoked` | License has been revoked |
| `license_expired` | License has expired |
| `license_inactive` | License is inactive |
| `domain_mismatch` | License not valid for this domain |
| `max_activations_reached` | Maximum activations reached |
| `rate_limit_exceeded` | Too many requests (wait and retry) |
## License Statuses ## License Statuses
- **Active**: License is valid and usable - **Active**: License is valid and usable
@@ -107,6 +208,18 @@ Content-Type: application/json
- **Expired**: License validity period has ended - **Expired**: License validity period has ended
- **Revoked**: License has been manually revoked by admin - **Revoked**: License has been manually revoked by admin
## Email Notifications
The plugin sends automatic email notifications:
- **Order Completion**: License keys included in order confirmation emails
- **Expiration Warning (7 days)**: Reminder sent 7 days before expiration
- **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for version history and changes.
## Support ## Support
For issues and feature requests, please visit: For issues and feature requests, please visit:

View File

@@ -165,3 +165,306 @@
vertical-align: middle; vertical-align: middle;
margin-left: 5px; margin-left: 5px;
} }
/* Licenses Table Bulk Actions */
.licenses-table {
margin-top: 0;
}
.licenses-table .check-column {
width: 2.2em;
padding: 0.5em;
}
.licenses-table .license-actions {
width: 150px;
}
.licenses-table .row-actions {
visibility: visible;
padding: 2px 0 0;
}
.licenses-table .row-actions a {
text-decoration: none;
}
.licenses-table .row-actions .submitdelete {
color: #b32d2e;
}
.licenses-table .row-actions .submitdelete:hover {
color: #dc3232;
}
/* Lifetime Badge */
.license-lifetime {
display: inline-block;
padding: 0.2em 0.5em;
font-size: 0.85em;
font-weight: 500;
background-color: #e7f3ff;
color: #2271b1;
border-radius: 3px;
}
/* Bulk Actions Styling */
.tablenav .actions select {
margin-right: 6px;
}
.tablenav .bulkactions {
padding: 0;
}
/* Transfer Modal */
.wclp-modal {
position: fixed;
z-index: 100000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.wclp-modal-content {
background-color: #fff;
margin: 10% auto;
padding: 20px 30px;
border-radius: 4px;
width: 500px;
max-width: 90%;
position: relative;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.wclp-modal-close {
position: absolute;
right: 15px;
top: 10px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
color: #666;
}
.wclp-modal-close:hover {
color: #000;
}
.wclp-modal h2 {
margin-top: 0;
padding-bottom: 10px;
border-bottom: 1px solid #ddd;
}
.wclp-modal .form-table th {
padding: 15px 10px 15px 0;
width: 120px;
}
.wclp-modal .submit {
border-top: 1px solid #ddd;
padding-top: 15px;
margin-bottom: 0;
}
/* Dashboard Styles */
.wclp-dashboard-stats {
margin-top: 20px;
}
.wclp-stat-cards {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 20px;
}
.wclp-stat-card {
flex: 1;
min-width: 150px;
max-width: 200px;
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
padding: 15px;
display: flex;
align-items: center;
gap: 12px;
}
.wclp-stat-icon {
font-size: 32px;
line-height: 1;
}
.wclp-stat-icon .dashicons {
font-size: 32px;
width: 32px;
height: 32px;
}
.wclp-stat-content {
display: flex;
flex-direction: column;
}
.wclp-stat-number {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.wclp-stat-label {
font-size: 12px;
color: #646970;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wclp-stat-total .wclp-stat-icon .dashicons {
color: #2271b1;
}
.wclp-stat-active .wclp-stat-icon .dashicons {
color: #00a32a;
}
.wclp-stat-inactive .wclp-stat-icon .dashicons {
color: #dba617;
}
.wclp-stat-expired .wclp-stat-icon .dashicons {
color: #d63638;
}
.wclp-stat-revoked .wclp-stat-icon .dashicons {
color: #646970;
}
.wclp-stat-row {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.wclp-stat-box {
flex: 1;
min-width: 280px;
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
padding: 15px 20px;
}
.wclp-stat-box.wclp-stat-full {
flex: 100%;
min-width: 100%;
}
.wclp-stat-box h3 {
margin-top: 0;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #ddd;
}
.wclp-stat-box .widefat {
border: none;
}
.wclp-stat-value {
text-align: right;
font-weight: 600;
}
.wclp-stat-value.wclp-warning {
color: #d63638;
}
/* Dashboard Bar Chart */
.wclp-chart-container {
overflow-x: auto;
}
.wclp-bar-chart {
display: flex;
align-items: flex-end;
gap: 10px;
height: 200px;
padding: 20px 0;
}
.wclp-bar-wrapper {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 50px;
}
.wclp-bar {
width: 100%;
max-width: 40px;
background: linear-gradient(180deg, #2271b1, #135e96);
border-radius: 3px 3px 0 0;
min-height: 4px;
position: relative;
transition: background 0.2s;
}
.wclp-bar:hover {
background: linear-gradient(180deg, #135e96, #0a4b78);
}
.wclp-bar-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
font-weight: 600;
color: #1d2327;
}
.wclp-bar-label {
margin-top: 8px;
font-size: 10px;
color: #646970;
text-align: center;
white-space: nowrap;
}
/* Dashboard Actions */
.wclp-dashboard-actions {
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
padding: 15px 20px;
margin-top: 20px;
}
.wclp-dashboard-actions h2 {
margin-top: 0;
margin-bottom: 15px;
}
.wclp-action-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.wclp-action-buttons .button .dashicons {
vertical-align: middle;
margin-right: 5px;
margin-top: -2px;
}
/* Wider actions column for transfer link */
.licenses-table .license-actions {
width: 220px;
}

View File

@@ -150,6 +150,39 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.license-domain-display {
display: flex;
align-items: center;
gap: 0.5em;
}
/* Transfer Button */
.wclp-transfer-btn {
display: inline-flex;
align-items: center;
gap: 0.25em;
padding: 0.2em 0.6em;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 0.85em;
color: #555;
cursor: pointer;
transition: all 0.2s ease;
}
.wclp-transfer-btn:hover {
background: #e5e5e5;
border-color: #ccc;
color: #333;
}
.wclp-transfer-btn .dashicons {
font-size: 14px;
width: 14px;
height: 14px;
}
/* Download Section */ /* Download Section */
.license-downloads { .license-downloads {
padding: 1em 1.5em; padding: 1em 1.5em;
@@ -323,4 +356,151 @@
width: 45%; width: 45%;
font-weight: 600; font-weight: 600;
} }
.license-domain-display {
flex-wrap: wrap;
}
.wclp-transfer-btn {
margin-top: 0.5em;
}
}
/* Transfer Modal */
.wclp-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
}
.wclp-modal-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
}
.wclp-modal-content {
position: relative;
background: #fff;
padding: 2em;
border-radius: 8px;
max-width: 450px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.wclp-modal-close {
position: absolute;
top: 0.5em;
right: 0.5em;
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: #666;
line-height: 1;
padding: 0.25em;
}
.wclp-modal-close:hover {
color: #333;
}
.wclp-modal-content h3 {
margin: 0 0 1.5em 0;
font-size: 1.25em;
}
.wclp-form-row {
margin-bottom: 1.5em;
}
.wclp-form-row label {
display: block;
font-weight: 600;
margin-bottom: 0.5em;
color: #333;
}
.wclp-form-row input[type="text"] {
width: 100%;
padding: 0.75em;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
}
.wclp-form-row input[type="text"]:focus {
outline: none;
border-color: #2271b1;
box-shadow: 0 0 0 1px #2271b1;
}
.wclp-current-domain {
margin: 0;
}
.wclp-current-domain code {
background: #f5f5f5;
padding: 0.4em 0.8em;
border-radius: 4px;
font-size: 1em;
}
.wclp-field-description {
margin: 0.5em 0 0 0;
font-size: 0.85em;
color: #666;
}
.wclp-form-actions {
display: flex;
gap: 1em;
margin-top: 2em;
}
.wclp-btn-primary {
background: #2271b1 !important;
border-color: #2271b1 !important;
color: #fff !important;
}
.wclp-btn-primary:hover {
background: #135e96 !important;
border-color: #135e96 !important;
}
.wclp-btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wclp-message {
margin-top: 1em;
padding: 0.75em 1em;
border-radius: 4px;
font-size: 0.95em;
}
.wclp-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.wclp-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
} }

View File

@@ -8,12 +8,29 @@
'use strict'; 'use strict';
var WCLicensedProductFrontend = { var WCLicensedProductFrontend = {
$modal: null,
$form: null,
init: function() { init: function() {
this.$modal = $('#wclp-transfer-modal');
this.$form = $('#wclp-transfer-form');
this.bindEvents(); this.bindEvents();
}, },
bindEvents: function() { bindEvents: function() {
$(document).on('click', '.copy-license-btn', this.copyLicenseKey); $(document).on('click', '.copy-license-btn', this.copyLicenseKey);
// Transfer modal events
$(document).on('click', '.wclp-transfer-btn', this.openTransferModal.bind(this));
$(document).on('click', '.wclp-modal-close, .wclp-modal-cancel, .wclp-modal-overlay', this.closeTransferModal.bind(this));
$(document).on('submit', '#wclp-transfer-form', this.submitTransfer.bind(this));
// Close modal on escape key
$(document).on('keyup', function(e) {
if (e.key === 'Escape') {
WCLicensedProductFrontend.closeTransferModal();
}
});
}, },
/** /**
@@ -80,6 +97,106 @@
$(this).remove(); $(this).remove();
}); });
}, 1500); }, 1500);
},
/**
* Open transfer modal
*/
openTransferModal: function(e) {
e.preventDefault();
var $btn = $(e.currentTarget);
var licenseId = $btn.data('license-id');
var currentDomain = $btn.data('current-domain');
$('#transfer-license-id').val(licenseId);
$('#transfer-current-domain').text(currentDomain);
$('#transfer-new-domain').val('');
$('#wclp-transfer-message').hide().removeClass('success error');
$('#wclp-transfer-submit').prop('disabled', false);
this.$modal.show();
$('#transfer-new-domain').focus();
},
/**
* Close transfer modal
*/
closeTransferModal: function(e) {
if (e && $(e.target).closest('.wclp-modal-content').length && !$(e.target).is('.wclp-modal-close, .wclp-modal-cancel')) {
return;
}
this.$modal.hide();
},
/**
* Submit transfer request
*/
submitTransfer: function(e) {
e.preventDefault();
var self = this;
var licenseId = $('#transfer-license-id').val();
var newDomain = $('#transfer-new-domain').val().trim();
var $message = $('#wclp-transfer-message');
var $submit = $('#wclp-transfer-submit');
// Basic validation
if (!newDomain) {
$message.text(wcLicensedProduct.strings.invalidDomain)
.removeClass('success').addClass('error').show();
return;
}
// Confirm transfer
if (!confirm(wcLicensedProduct.strings.transferConfirm)) {
return;
}
// Disable submit button
$submit.prop('disabled', true);
$message.hide();
// Send AJAX request
$.ajax({
url: wcLicensedProduct.ajaxUrl,
type: 'POST',
data: {
action: 'wclp_customer_transfer_license',
nonce: wcLicensedProduct.transferNonce,
license_id: licenseId,
new_domain: newDomain
},
success: function(response) {
if (response.success) {
$message.text(response.data.message)
.removeClass('error').addClass('success').show();
// Update the domain display in the license card
var $domainDisplay = $('.license-domain-display[data-license-id="' + licenseId + '"]');
$domainDisplay.find('.domain-value').text(response.data.new_domain);
$domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain);
// Close modal after a short delay
setTimeout(function() {
self.closeTransferModal();
}, 1500);
} else {
$message.text(response.data.message || wcLicensedProduct.strings.transferError)
.removeClass('success').addClass('error').show();
$submit.prop('disabled', false);
}
},
error: function(xhr) {
var message = wcLicensedProduct.strings.transferError;
if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
message = xhr.responseJSON.data.message;
}
$message.text(message)
.removeClass('success').addClass('error').show();
$submit.prop('disabled', false);
}
});
} }
}; };

538
openapi.json Normal file
View File

@@ -0,0 +1,538 @@
{
"openapi": "3.1.0",
"info": {
"title": "WooCommerce Licensed Product API",
"description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.",
"version": "0.0.7",
"contact": {
"name": "Marco Graetsch",
"url": "https://src.bundespruefstelle.ch/magdev",
"email": "magdev3.0@gmail.com"
},
"license": {
"name": "GPL-2.0-or-later",
"url": "https://www.gnu.org/licenses/gpl-2.0.html"
}
},
"servers": [
{
"url": "{baseUrl}/wp-json/wc-licensed-product/v1",
"description": "WordPress REST API endpoint",
"variables": {
"baseUrl": {
"default": "https://example.com",
"description": "The base URL of your WordPress installation"
}
}
}
],
"paths": {
"/validate": {
"post": {
"operationId": "validateLicense",
"summary": "Validate a license key for a domain",
"description": "Validates whether a license key is valid for a specific domain. Checks license status, expiration, and domain binding. This is the primary endpoint for software to verify license validity.",
"tags": ["License Validation"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateRequest"
},
"example": {
"license_key": "ABCD-1234-EFGH-5678",
"domain": "example.com"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/ValidateRequest"
}
}
}
},
"responses": {
"200": {
"description": "License is valid for the specified domain",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateSuccessResponse"
},
"example": {
"valid": true,
"license": {
"product_id": 123,
"expires_at": "2027-01-21",
"version_id": 5
}
}
}
}
},
"403": {
"description": "License validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidateErrorResponse"
},
"examples": {
"license_not_found": {
"summary": "License key not found",
"value": {
"valid": false,
"error": "license_not_found",
"message": "License key not found."
}
},
"license_revoked": {
"summary": "License has been revoked",
"value": {
"valid": false,
"error": "license_revoked",
"message": "This license has been revoked."
}
},
"license_expired": {
"summary": "License has expired",
"value": {
"valid": false,
"error": "license_expired",
"message": "This license has expired."
}
},
"license_inactive": {
"summary": "License is inactive",
"value": {
"valid": false,
"error": "license_inactive",
"message": "This license is inactive."
}
},
"domain_mismatch": {
"summary": "License not valid for this domain",
"value": {
"valid": false,
"error": "domain_mismatch",
"message": "This license is not valid for this domain."
}
}
}
}
}
},
"429": {
"$ref": "#/components/responses/RateLimitExceeded"
}
}
}
},
"/status": {
"post": {
"operationId": "checkStatus",
"summary": "Get license status information",
"description": "Retrieves detailed status information for a license key, including validity, domain binding, expiration date, and activation counts.",
"tags": ["License Status"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatusRequest"
},
"example": {
"license_key": "ABCD-1234-EFGH-5678"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/StatusRequest"
}
}
}
},
"responses": {
"200": {
"description": "License status retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatusResponse"
},
"example": {
"valid": true,
"status": "active",
"domain": "example.com",
"expires_at": "2027-01-21",
"activations_count": 1,
"max_activations": 3
}
}
}
},
"404": {
"description": "License key not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"valid": false,
"error": "license_not_found",
"message": "License key not found."
}
}
}
},
"429": {
"$ref": "#/components/responses/RateLimitExceeded"
}
}
}
},
"/activate": {
"post": {
"operationId": "activateLicense",
"summary": "Activate a license on a domain",
"description": "Activates a license key on a specific domain. If the license is already activated on the same domain, returns success. If activating on a new domain, the old domain binding is replaced (single-domain licenses).",
"tags": ["License Activation"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ActivateRequest"
},
"example": {
"license_key": "ABCD-1234-EFGH-5678",
"domain": "newdomain.com"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/ActivateRequest"
}
}
}
},
"responses": {
"200": {
"description": "License activated successfully or already activated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ActivateSuccessResponse"
},
"examples": {
"activated": {
"summary": "License activated on new domain",
"value": {
"success": true,
"message": "License activated successfully."
}
},
"already_activated": {
"summary": "License already activated on this domain",
"value": {
"success": true,
"message": "License is already activated for this domain."
}
}
}
}
}
},
"403": {
"description": "Activation failed due to license restrictions",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"examples": {
"license_invalid": {
"summary": "License is not valid",
"value": {
"success": false,
"error": "license_invalid",
"message": "This license is not valid."
}
},
"max_activations_reached": {
"summary": "Maximum activations reached",
"value": {
"success": false,
"error": "max_activations_reached",
"message": "Maximum number of activations reached."
}
}
}
}
}
},
"404": {
"description": "License key not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"success": false,
"error": "license_not_found",
"message": "License key not found."
}
}
}
},
"429": {
"$ref": "#/components/responses/RateLimitExceeded"
},
"500": {
"description": "Server error during activation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"example": {
"success": false,
"error": "activation_failed",
"message": "Failed to activate license."
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ValidateRequest": {
"type": "object",
"required": ["license_key", "domain"],
"properties": {
"license_key": {
"type": "string",
"description": "The license key to validate (format: XXXX-XXXX-XXXX-XXXX)",
"maxLength": 64,
"example": "ABCD-1234-EFGH-5678"
},
"domain": {
"type": "string",
"description": "The domain to validate the license against",
"maxLength": 255,
"example": "example.com"
}
}
},
"ValidateSuccessResponse": {
"type": "object",
"properties": {
"valid": {
"type": "boolean",
"const": true,
"description": "Indicates the license is valid"
},
"license": {
"type": "object",
"properties": {
"product_id": {
"type": "integer",
"description": "WooCommerce product ID associated with the license"
},
"expires_at": {
"type": ["string", "null"],
"format": "date",
"description": "Expiration date (null for lifetime licenses)"
},
"version_id": {
"type": ["integer", "null"],
"description": "Product version ID if license is bound to a version"
}
}
}
}
},
"ValidateErrorResponse": {
"type": "object",
"properties": {
"valid": {
"type": "boolean",
"const": false,
"description": "Indicates validation failed"
},
"error": {
"type": "string",
"enum": ["license_not_found", "license_revoked", "license_expired", "license_inactive", "domain_mismatch"],
"description": "Error code for programmatic handling"
},
"message": {
"type": "string",
"description": "Human-readable error message"
}
}
},
"StatusRequest": {
"type": "object",
"required": ["license_key"],
"properties": {
"license_key": {
"type": "string",
"description": "The license key to check",
"example": "ABCD-1234-EFGH-5678"
}
}
},
"StatusResponse": {
"type": "object",
"properties": {
"valid": {
"type": "boolean",
"description": "Whether the license is currently valid"
},
"status": {
"type": "string",
"enum": ["active", "inactive", "expired", "revoked"],
"description": "Current license status"
},
"domain": {
"type": "string",
"description": "Domain the license is bound to"
},
"expires_at": {
"type": ["string", "null"],
"format": "date",
"description": "Expiration date (null for lifetime licenses)"
},
"activations_count": {
"type": "integer",
"description": "Current number of activations"
},
"max_activations": {
"type": "integer",
"description": "Maximum allowed activations"
}
}
},
"ActivateRequest": {
"type": "object",
"required": ["license_key", "domain"],
"properties": {
"license_key": {
"type": "string",
"description": "The license key to activate",
"example": "ABCD-1234-EFGH-5678"
},
"domain": {
"type": "string",
"description": "The domain to activate the license on",
"example": "newdomain.com"
}
}
},
"ActivateSuccessResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"const": true,
"description": "Indicates activation was successful"
},
"message": {
"type": "string",
"description": "Human-readable success message"
}
}
},
"ErrorResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"const": false,
"description": "Indicates the operation failed"
},
"valid": {
"type": "boolean",
"const": false,
"description": "Indicates validation failed (for validation endpoints)"
},
"error": {
"type": "string",
"description": "Error code for programmatic handling"
},
"message": {
"type": "string",
"description": "Human-readable error message"
}
}
},
"RateLimitResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"const": false
},
"error": {
"type": "string",
"const": "rate_limit_exceeded"
},
"message": {
"type": "string",
"example": "Too many requests. Please try again later."
},
"retry_after": {
"type": "integer",
"description": "Seconds until rate limit resets"
}
}
}
},
"responses": {
"RateLimitExceeded": {
"description": "Rate limit exceeded (30 requests per minute per IP)",
"headers": {
"Retry-After": {
"schema": {
"type": "integer"
},
"description": "Seconds until the rate limit resets"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RateLimitResponse"
},
"example": {
"success": false,
"error": "rate_limit_exceeded",
"message": "Too many requests. Please try again later.",
"retry_after": 45
}
}
}
}
}
},
"tags": [
{
"name": "License Validation",
"description": "Validate license keys against domains"
},
{
"name": "License Status",
"description": "Check license status and details"
},
{
"name": "License Activation",
"description": "Activate licenses on domains"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
<?php
/**
* Settings Controller
*
* @package Jeremias\WcLicensedProduct\Admin
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Admin;
/**
* Handles WooCommerce settings tab for license defaults
*/
final class SettingsController
{
/**
* Settings option name
*/
public const OPTION_NAME = 'wc_licensed_product_settings';
/**
* Constructor
*/
public function __construct()
{
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
add_filter('woocommerce_settings_tabs_array', [$this, 'addSettingsTab'], 50);
add_action('woocommerce_settings_tabs_licensed_product', [$this, 'renderSettingsTab']);
add_action('woocommerce_update_options_licensed_product', [$this, 'saveSettings']);
}
/**
* Add settings tab to WooCommerce settings
*/
public function addSettingsTab(array $tabs): array
{
$tabs['licensed_product'] = __('Licensed Products', 'wc-licensed-product');
return $tabs;
}
/**
* Get settings fields
*/
public function getSettingsFields(): array
{
return [
'section_title' => [
'name' => __('Default License Settings', 'wc-licensed-product'),
'type' => 'title',
'desc' => __('These settings serve as defaults for new licensed products. Individual product settings override these defaults.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_section_defaults',
],
'default_max_activations' => [
'name' => __('Default Max Activations', 'wc-licensed-product'),
'type' => 'number',
'desc' => __('Default maximum number of domain activations per license.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_default_max_activations',
'default' => '1',
'custom_attributes' => [
'min' => '1',
'step' => '1',
],
],
'default_validity_days' => [
'name' => __('Default License Validity (Days)', 'wc-licensed-product'),
'type' => 'number',
'desc' => __('Default number of days a license is valid. Leave empty or set to 0 for lifetime licenses.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_default_validity_days',
'default' => '',
'placeholder' => __('Lifetime', 'wc-licensed-product'),
'custom_attributes' => [
'min' => '0',
'step' => '1',
],
],
'default_bind_to_version' => [
'name' => __('Default Bind to Major Version', 'wc-licensed-product'),
'type' => 'checkbox',
'desc' => __('If enabled, licenses are bound to the major version at purchase time by default.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_default_bind_to_version',
'default' => 'no',
],
'section_end' => [
'type' => 'sectionend',
'id' => 'wc_licensed_product_section_defaults_end',
],
];
}
/**
* Render settings tab content
*/
public function renderSettingsTab(): void
{
woocommerce_admin_fields($this->getSettingsFields());
}
/**
* Save settings
*/
public function saveSettings(): void
{
woocommerce_update_options($this->getSettingsFields());
}
/**
* Get default max activations
*/
public static function getDefaultMaxActivations(): int
{
$value = get_option('wc_licensed_product_default_max_activations', 1);
return max(1, (int) $value);
}
/**
* Get default validity days
*/
public static function getDefaultValidityDays(): ?int
{
$value = get_option('wc_licensed_product_default_validity_days', '');
if ($value === '' || $value === '0') {
return null;
}
return (int) $value;
}
/**
* Get default bind to version setting
*/
public static function getDefaultBindToVersion(): bool
{
return get_option('wc_licensed_product_default_bind_to_version', 'no') === 'yes';
}
}

View File

@@ -180,25 +180,6 @@ final class RestApiController
], ],
], ],
]); ]);
// Deactivate license endpoint (public)
register_rest_route(self::NAMESPACE, '/deactivate', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'deactivateLicense'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
'domain' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
} }
/** /**
@@ -318,55 +299,4 @@ final class RestApiController
'message' => __('License activated successfully.', 'wc-licensed-product'), 'message' => __('License activated successfully.', 'wc-licensed-product'),
]); ]);
} }
/**
* Deactivate license endpoint
*/
public function deactivateLicense(WP_REST_Request $request): WP_REST_Response
{
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
$license = $this->licenseManager->getLicenseByKey($licenseKey);
if (!$license) {
return new WP_REST_Response([
'success' => false,
'error' => 'license_not_found',
'message' => __('License key not found.', 'wc-licensed-product'),
], 404);
}
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
// Verify domain matches
if ($license->getDomain() !== $normalizedDomain) {
return new WP_REST_Response([
'success' => false,
'error' => 'domain_mismatch',
'message' => __('License is not activated for this domain.', 'wc-licensed-product'),
], 403);
}
// Set status to inactive
$success = $this->licenseManager->updateLicenseStatus($license->getId(), 'inactive');
if (!$success) {
return new WP_REST_Response([
'success' => false,
'error' => 'deactivation_failed',
'message' => __('Failed to deactivate license.', 'wc-licensed-product'),
], 500);
}
return new WP_REST_Response([
'success' => true,
'message' => __('License deactivated successfully.', 'wc-licensed-product'),
]);
}
} }

View File

@@ -34,6 +34,210 @@ final class LicenseEmailController
// Add license info to order details in emails // Add license info to order details in emails
add_action('woocommerce_order_item_meta_end', [$this, 'addLicenseToOrderItem'], 10, 4); add_action('woocommerce_order_item_meta_end', [$this, 'addLicenseToOrderItem'], 10, 4);
// Schedule cron job for expiration warnings
add_action('init', [$this, 'scheduleExpirationCheck']);
// Cron action for checking expiring licenses
add_action('wclp_check_expiring_licenses', [$this, 'sendExpirationWarnings']);
}
/**
* Schedule the expiration check cron job
*/
public function scheduleExpirationCheck(): void
{
if (!wp_next_scheduled('wclp_check_expiring_licenses')) {
wp_schedule_event(time(), 'daily', 'wclp_check_expiring_licenses');
}
}
/**
* Send expiration warning emails
*/
public function sendExpirationWarnings(): void
{
// Check for licenses expiring in 7 days
$this->processExpirationWarnings(7, 'expiring_7_days');
// Check for licenses expiring in 1 day
$this->processExpirationWarnings(1, 'expiring_1_day');
}
/**
* Process and send expiration warnings for a specific time frame
*
* @param int $days Days until expiration
* @param string $notificationType Notification type identifier
*/
private function processExpirationWarnings(int $days, string $notificationType): void
{
$licenses = $this->licenseManager->getLicensesExpiringSoon($days);
foreach ($licenses as $license) {
// Skip if already notified
if ($this->licenseManager->wasExpirationNotified($license->getId(), $notificationType)) {
continue;
}
// Send the warning email
if ($this->sendExpirationWarningEmail($license, $days)) {
// Mark as notified
$this->licenseManager->markExpirationNotified($license->getId(), $notificationType);
}
}
}
/**
* Send expiration warning email to customer
*
* @param \Jeremias\WcLicensedProduct\License\License $license License object
* @param int $daysRemaining Days until expiration
* @return bool Whether email was sent successfully
*/
private function sendExpirationWarningEmail($license, int $daysRemaining): bool
{
$customer = get_userdata($license->getCustomerId());
if (!$customer || !$customer->user_email) {
return false;
}
$product = wc_get_product($license->getProductId());
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
$siteName = get_bloginfo('name');
$expiresAt = $license->getExpiresAt();
$expirationDate = $expiresAt ? $expiresAt->format(get_option('date_format')) : '';
// Email subject
if ($daysRemaining === 1) {
$subject = sprintf(
/* translators: 1: Product name, 2: Site name */
__('[%2$s] Your license for %1$s expires tomorrow', 'wc-licensed-product'),
$productName,
$siteName
);
} else {
$subject = sprintf(
/* translators: 1: Product name, 2: Number of days, 3: Site name */
__('[%3$s] Your license for %1$s expires in %2$d days', 'wc-licensed-product'),
$productName,
$daysRemaining,
$siteName
);
}
// Email content
$message = $this->buildExpirationWarningHtml($license, $customer, $productName, $daysRemaining, $expirationDate);
// Send email
$headers = [
'Content-Type: text/html; charset=UTF-8',
'From: ' . $siteName . ' <' . get_option('admin_email') . '>',
];
return wp_mail($customer->user_email, $subject, $message, $headers);
}
/**
* Build HTML content for expiration warning email
*
* @param \Jeremias\WcLicensedProduct\License\License $license License object
* @param \WP_User $customer Customer user object
* @param string $productName Product name
* @param int $daysRemaining Days until expiration
* @param string $expirationDate Formatted expiration date
* @return string HTML email content
*/
private function buildExpirationWarningHtml($license, $customer, string $productName, int $daysRemaining, string $expirationDate): string
{
$siteName = get_bloginfo('name');
$siteUrl = home_url();
$accountUrl = wc_get_account_endpoint_url('licenses');
ob_start();
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: #f8f9fa; padding: 30px; border-radius: 8px;">
<h1 style="color: #333; margin-top: 0; font-size: 24px;">
<?php esc_html_e('License Expiration Notice', 'wc-licensed-product'); ?>
</h1>
<p><?php printf(esc_html__('Hello %s,', 'wc-licensed-product'), esc_html($customer->display_name)); ?></p>
<?php if ($daysRemaining === 1): ?>
<p style="color: #dc3545; font-weight: 600;">
<?php printf(
esc_html__('Your license for %s will expire tomorrow (%s).', 'wc-licensed-product'),
esc_html($productName),
esc_html($expirationDate)
); ?>
</p>
<?php else: ?>
<p style="color: #ffc107; font-weight: 600;">
<?php printf(
esc_html__('Your license for %1$s will expire in %2$d days (%3$s).', 'wc-licensed-product'),
esc_html($productName),
$daysRemaining,
esc_html($expirationDate)
); ?>
</p>
<?php endif; ?>
<div style="background: #fff; padding: 20px; border-radius: 4px; margin: 20px 0; border: 1px solid #e5e5e5;">
<h3 style="margin-top: 0; font-size: 16px;"><?php esc_html_e('License Details', 'wc-licensed-product'); ?></h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666;"><?php esc_html_e('Product:', 'wc-licensed-product'); ?></td>
<td style="padding: 8px 0; font-weight: 600;"><?php echo esc_html($productName); ?></td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></td>
<td style="padding: 8px 0;">
<code style="background: #f5f5f5; padding: 3px 8px; border-radius: 3px; font-family: monospace;">
<?php echo esc_html($license->getLicenseKey()); ?>
</code>
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></td>
<td style="padding: 8px 0;"><?php echo esc_html($license->getDomain()); ?></td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;"><?php esc_html_e('Expires:', 'wc-licensed-product'); ?></td>
<td style="padding: 8px 0; color: #dc3545; font-weight: 600;"><?php echo esc_html($expirationDate); ?></td>
</tr>
</table>
</div>
<p><?php esc_html_e('To continue using this product, please renew your license before the expiration date.', 'wc-licensed-product'); ?></p>
<p style="margin-top: 25px;">
<a href="<?php echo esc_url($accountUrl); ?>"
style="display: inline-block; background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: 600;">
<?php esc_html_e('View My Licenses', 'wc-licensed-product'); ?>
</a>
</p>
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
<p style="font-size: 14px; color: #666; margin-bottom: 0;">
<?php printf(
esc_html__('This email was sent from %s.', 'wc-licensed-product'),
'<a href="' . esc_url($siteUrl) . '" style="color: #2271b1;">' . esc_html($siteName) . '</a>'
); ?>
</p>
</div>
</body>
</html>
<?php
return ob_get_clean();
} }
/** /**

View File

@@ -52,6 +52,9 @@ final class AccountController
// Enqueue frontend styles and scripts // Enqueue frontend styles and scripts
add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']); add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']);
// AJAX handler for license transfer request
add_action('wp_ajax_wclp_customer_transfer_license', [$this, 'handleTransferRequest']);
} }
/** /**
@@ -181,7 +184,19 @@ final class AccountController
</div> </div>
<div class="license-info-row"> <div class="license-info-row">
<span><strong><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></strong> <?php echo esc_html($item['license']->getDomain()); ?></span> <span class="license-domain-display" data-license-id="<?php echo esc_attr($item['license']->getId()); ?>">
<strong><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></strong>
<span class="domain-value"><?php echo esc_html($item['license']->getDomain()); ?></span>
<?php if (in_array($item['license']->getStatus(), ['active', 'inactive'], true)): ?>
<button type="button" class="wclp-transfer-btn"
data-license-id="<?php echo esc_attr($item['license']->getId()); ?>"
data-current-domain="<?php echo esc_attr($item['license']->getDomain()); ?>"
title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>">
<span class="dashicons dashicons-randomize"></span>
<?php esc_html_e('Transfer', 'wc-licensed-product'); ?>
</button>
<?php endif; ?>
</span>
<span><strong><?php esc_html_e('Expires:', 'wc-licensed-product'); ?></strong> <span><strong><?php esc_html_e('Expires:', 'wc-licensed-product'); ?></strong>
<?php <?php
$expiresAt = $item['license']->getExpiresAt(); $expiresAt = $item['license']->getExpiresAt();
@@ -213,6 +228,40 @@ final class AccountController
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<!-- Transfer Modal -->
<div id="wclp-transfer-modal" class="wclp-modal" style="display:none;">
<div class="wclp-modal-overlay"></div>
<div class="wclp-modal-content">
<button type="button" class="wclp-modal-close" aria-label="<?php esc_attr_e('Close', 'wc-licensed-product'); ?>">&times;</button>
<h3><?php esc_html_e('Transfer License to New Domain', 'wc-licensed-product'); ?></h3>
<form id="wclp-transfer-form">
<input type="hidden" name="license_id" id="transfer-license-id" value="">
<div class="wclp-form-row">
<label><?php esc_html_e('Current Domain', 'wc-licensed-product'); ?></label>
<p class="wclp-current-domain"><code id="transfer-current-domain"></code></p>
</div>
<div class="wclp-form-row">
<label for="transfer-new-domain"><?php esc_html_e('New Domain', 'wc-licensed-product'); ?></label>
<input type="text" name="new_domain" id="transfer-new-domain"
placeholder="example.com" required
pattern="[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+">
<p class="wclp-field-description"><?php esc_html_e('Enter the new domain without http:// or www.', 'wc-licensed-product'); ?></p>
</div>
<div class="wclp-form-row wclp-form-actions">
<button type="submit" class="button wclp-btn-primary" id="wclp-transfer-submit">
<?php esc_html_e('Transfer License', 'wc-licensed-product'); ?>
</button>
<button type="button" class="button wclp-modal-cancel"><?php esc_html_e('Cancel', 'wc-licensed-product'); ?></button>
</div>
<div id="wclp-transfer-message" class="wclp-message" style="display:none;"></div>
</form>
</div>
</div>
<?php <?php
} }
@@ -241,10 +290,111 @@ final class AccountController
); );
wp_localize_script('wc-licensed-product-frontend', 'wcLicensedProduct', [ wp_localize_script('wc-licensed-product-frontend', 'wcLicensedProduct', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'transferNonce' => wp_create_nonce('wclp_customer_transfer'),
'strings' => [ 'strings' => [
'copied' => __('Copied!', 'wc-licensed-product'), 'copied' => __('Copied!', 'wc-licensed-product'),
'copyFailed' => __('Copy failed', 'wc-licensed-product'), 'copyFailed' => __('Copy failed', 'wc-licensed-product'),
'transferSuccess' => __('License transferred successfully!', 'wc-licensed-product'),
'transferError' => __('Transfer failed. Please try again.', 'wc-licensed-product'),
'transferConfirm' => __('Are you sure you want to transfer this license to a new domain? This action cannot be undone.', 'wc-licensed-product'),
'invalidDomain' => __('Please enter a valid domain.', 'wc-licensed-product'),
], ],
]); ]);
} }
/**
* Handle AJAX license transfer request from customer
*/
public function handleTransferRequest(): void
{
// Verify nonce
if (!check_ajax_referer('wclp_customer_transfer', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403);
}
// Verify user is logged in
$customerId = get_current_user_id();
if (!$customerId) {
wp_send_json_error(['message' => __('Please log in to transfer a license.', 'wc-licensed-product')], 401);
}
// Get and validate license ID
$licenseId = isset($_POST['license_id']) ? absint($_POST['license_id']) : 0;
if (!$licenseId) {
wp_send_json_error(['message' => __('Invalid license.', 'wc-licensed-product')], 400);
}
// Get and validate new domain
$newDomain = isset($_POST['new_domain']) ? sanitize_text_field($_POST['new_domain']) : '';
$newDomain = $this->normalizeDomain($newDomain);
if (empty($newDomain)) {
wp_send_json_error(['message' => __('Please enter a valid domain.', 'wc-licensed-product')], 400);
}
// Verify the license belongs to this customer
$license = $this->licenseManager->getLicenseById($licenseId);
if (!$license) {
wp_send_json_error(['message' => __('License not found.', 'wc-licensed-product')], 404);
}
if ($license->getCustomerId() !== $customerId) {
wp_send_json_error(['message' => __('You do not have permission to transfer this license.', 'wc-licensed-product')], 403);
}
// Check if license is in a transferable state
if ($license->getStatus() === 'revoked') {
wp_send_json_error(['message' => __('Revoked licenses cannot be transferred.', 'wc-licensed-product')], 400);
}
if ($license->getStatus() === 'expired') {
wp_send_json_error(['message' => __('Expired licenses cannot be transferred.', 'wc-licensed-product')], 400);
}
// Check if domain is the same
if ($license->getDomain() === $newDomain) {
wp_send_json_error(['message' => __('The new domain is the same as the current domain.', 'wc-licensed-product')], 400);
}
// Perform the transfer
$result = $this->licenseManager->transferLicense($licenseId, $newDomain);
if ($result) {
wp_send_json_success([
'message' => __('License transferred successfully!', 'wc-licensed-product'),
'new_domain' => $newDomain,
]);
} else {
wp_send_json_error(['message' => __('Failed to transfer license. Please try again.', 'wc-licensed-product')], 500);
}
}
/**
* Normalize domain for comparison and storage
*/
private function normalizeDomain(string $domain): string
{
// Remove protocol if present
$domain = preg_replace('#^https?://#i', '', $domain);
// Remove www prefix
$domain = preg_replace('#^www\.#i', '', $domain);
// Remove trailing slash
$domain = rtrim($domain, '/');
// Remove path if present
$domain = explode('/', $domain)[0];
// Convert to lowercase
$domain = strtolower($domain);
// Basic validation - must contain at least one dot and valid characters
if (!preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/', $domain)) {
return '';
}
return $domain;
}
} }

View File

@@ -44,6 +44,12 @@ final class Installer
*/ */
public static function deactivate(): void public static function deactivate(): void
{ {
// Clear scheduled cron events
$timestamp = wp_next_scheduled('wclp_check_expiring_licenses');
if ($timestamp) {
wp_unschedule_event($timestamp, 'wclp_check_expiring_licenses');
}
// Flush rewrite rules // Flush rewrite rules
flush_rewrite_rules(); flush_rewrite_rules();
} }

View File

@@ -176,21 +176,63 @@ class LicenseManager
} }
/** /**
* Get all licenses (for admin) * Get all licenses (for admin) with optional filtering
*
* @param int $page Page number
* @param int $perPage Items per page
* @param array $filters Optional filters: search, status, product_id, customer_id
* @return array Array of License objects
*/ */
public function getAllLicenses(int $page = 1, int $perPage = 20): array public function getAllLicenses(int $page = 1, int $perPage = 20, array $filters = []): array
{ {
global $wpdb; global $wpdb;
$tableName = Installer::getLicensesTable(); $tableName = Installer::getLicensesTable();
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
$where = [];
$params = [];
// Search filter (searches license key, domain, customer email)
if (!empty($filters['search'])) {
$search = '%' . $wpdb->esc_like($filters['search']) . '%';
$where[] = "(license_key LIKE %s OR domain LIKE %s)";
$params[] = $search;
$params[] = $search;
}
// Status filter
if (!empty($filters['status']) && in_array($filters['status'], [
License::STATUS_ACTIVE,
License::STATUS_INACTIVE,
License::STATUS_EXPIRED,
License::STATUS_REVOKED,
], true)) {
$where[] = "status = %s";
$params[] = $filters['status'];
}
// Product filter
if (!empty($filters['product_id'])) {
$where[] = "product_id = %d";
$params[] = absint($filters['product_id']);
}
// Customer filter
if (!empty($filters['customer_id'])) {
$where[] = "customer_id = %d";
$params[] = absint($filters['customer_id']);
}
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
$params[] = $perPage;
$params[] = $offset;
$sql = "SELECT * FROM {$tableName} {$whereClause} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$rows = $wpdb->get_results( $rows = $wpdb->get_results(
$wpdb->prepare( $wpdb->prepare($sql, $params),
"SELECT * FROM {$tableName} ORDER BY created_at DESC LIMIT %d OFFSET %d",
$perPage,
$offset
),
ARRAY_A ARRAY_A
); );
@@ -198,16 +240,85 @@ class LicenseManager
} }
/** /**
* Get total license count * Get total license count with optional filtering
*
* @param array $filters Optional filters: search, status, product_id, customer_id
* @return int Total count
*/ */
public function getLicenseCount(): int public function getLicenseCount(array $filters = []): int
{ {
global $wpdb; global $wpdb;
$tableName = Installer::getLicensesTable(); $tableName = Installer::getLicensesTable();
$where = [];
$params = [];
// Search filter
if (!empty($filters['search'])) {
$search = '%' . $wpdb->esc_like($filters['search']) . '%';
$where[] = "(license_key LIKE %s OR domain LIKE %s)";
$params[] = $search;
$params[] = $search;
}
// Status filter
if (!empty($filters['status']) && in_array($filters['status'], [
License::STATUS_ACTIVE,
License::STATUS_INACTIVE,
License::STATUS_EXPIRED,
License::STATUS_REVOKED,
], true)) {
$where[] = "status = %s";
$params[] = $filters['status'];
}
// Product filter
if (!empty($filters['product_id'])) {
$where[] = "product_id = %d";
$params[] = absint($filters['product_id']);
}
// Customer filter
if (!empty($filters['customer_id'])) {
$where[] = "customer_id = %d";
$params[] = absint($filters['customer_id']);
}
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
if (empty($params)) {
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tableName}"); return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tableName}");
} }
return (int) $wpdb->get_var(
$wpdb->prepare("SELECT COUNT(*) FROM {$tableName} {$whereClause}", $params)
);
}
/**
* Get all licensed products for filter dropdown
*
* @return array Array of [id => name] pairs
*/
public function getLicensedProducts(): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$productIds = $wpdb->get_col("SELECT DISTINCT product_id FROM {$tableName}");
$products = [];
foreach ($productIds as $productId) {
$product = wc_get_product((int) $productId);
if ($product) {
$products[(int) $productId] = $product->get_name();
}
}
return $products;
}
/** /**
* Validate a license key for a domain * Validate a license key for a domain
*/ */
@@ -360,4 +471,430 @@ class LicenseManager
return $versionId ? (int) $versionId : null; return $versionId ? (int) $versionId : null;
} }
/**
* Extend license expiration
*
* @param int $licenseId License ID
* @param int $days Number of days to extend
* @return bool Success
*/
public function extendLicense(int $licenseId, int $days): bool
{
global $wpdb;
$license = $this->getLicenseById($licenseId);
if (!$license) {
return false;
}
// Calculate new expiration date
$currentExpiry = $license->getExpiresAt();
if ($currentExpiry === null) {
// License is lifetime, set expiration from now
$newExpiry = (new \DateTimeImmutable())->modify("+{$days} days");
} elseif ($currentExpiry < new \DateTimeImmutable()) {
// License is expired, extend from now
$newExpiry = (new \DateTimeImmutable())->modify("+{$days} days");
} else {
// License still valid, extend from current expiry
$newExpiry = \DateTimeImmutable::createFromInterface($currentExpiry)->modify("+{$days} days");
}
$tableName = Installer::getLicensesTable();
$result = $wpdb->update(
$tableName,
['expires_at' => $newExpiry->format('Y-m-d H:i:s')],
['id' => $licenseId],
['%s'],
['%d']
);
// If license was expired, reactivate it
if ($result !== false && $license->getStatus() === License::STATUS_EXPIRED) {
$this->updateLicenseStatus($licenseId, License::STATUS_ACTIVE);
}
return $result !== false;
}
/**
* Set license to lifetime (no expiration)
*
* @param int $licenseId License ID
* @return bool Success
*/
public function setLicenseLifetime(int $licenseId): bool
{
global $wpdb;
$license = $this->getLicenseById($licenseId);
$tableName = Installer::getLicensesTable();
// Use raw query to set NULL
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$tableName} SET expires_at = NULL WHERE id = %d",
$licenseId
)
);
// If license was expired, reactivate it
if ($result !== false && $license && $license->getStatus() === License::STATUS_EXPIRED) {
$this->updateLicenseStatus($licenseId, License::STATUS_ACTIVE);
}
return $result !== false;
}
/**
* Bulk update license status
*
* @param array $licenseIds Array of license IDs
* @param string $status New status
* @return int Number of licenses updated
*/
public function bulkUpdateStatus(array $licenseIds, string $status): int
{
global $wpdb;
if (empty($licenseIds)) {
return 0;
}
$tableName = Installer::getLicensesTable();
$ids = array_map('absint', $licenseIds);
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$tableName} SET status = %s WHERE id IN ({$placeholders})",
array_merge([$status], $ids)
)
);
return $result !== false ? (int) $result : 0;
}
/**
* Bulk delete licenses
*
* @param array $licenseIds Array of license IDs
* @return int Number of licenses deleted
*/
public function bulkDelete(array $licenseIds): int
{
global $wpdb;
if (empty($licenseIds)) {
return 0;
}
$tableName = Installer::getLicensesTable();
$ids = array_map('absint', $licenseIds);
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$result = $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$tableName} WHERE id IN ({$placeholders})",
$ids
)
);
return $result !== false ? (int) $result : 0;
}
/**
* Bulk extend licenses
*
* @param array $licenseIds Array of license IDs
* @param int $days Number of days to extend
* @return int Number of licenses extended
*/
public function bulkExtend(array $licenseIds, int $days): int
{
$count = 0;
foreach ($licenseIds as $licenseId) {
if ($this->extendLicense((int) $licenseId, $days)) {
$count++;
}
}
return $count;
}
/**
* Transfer license to a new domain
*
* @param int $licenseId License ID
* @param string $newDomain New domain to transfer to
* @return bool Success
*/
public function transferLicense(int $licenseId, string $newDomain): bool
{
$license = $this->getLicenseById($licenseId);
if (!$license) {
return false;
}
// Cannot transfer revoked licenses
if ($license->getStatus() === License::STATUS_REVOKED) {
return false;
}
return $this->updateLicenseDomain($licenseId, $newDomain);
}
/**
* Get license statistics
*
* @return array Statistics data
*/
public function getStatistics(): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
// Get counts by status
$statusCounts = $wpdb->get_results(
"SELECT status, COUNT(*) as count FROM {$tableName} GROUP BY status",
ARRAY_A
);
$byStatus = [
License::STATUS_ACTIVE => 0,
License::STATUS_INACTIVE => 0,
License::STATUS_EXPIRED => 0,
License::STATUS_REVOKED => 0,
];
foreach ($statusCounts ?: [] as $row) {
$byStatus[$row['status']] = (int) $row['count'];
}
// Get total count
$total = array_sum($byStatus);
// Get lifetime vs expiring licenses
$lifetimeCount = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$tableName} WHERE expires_at IS NULL"
);
$expiringCount = $total - $lifetimeCount;
// Get licenses expiring soon (next 30 days)
$expiringSoon = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$tableName} WHERE expires_at IS NOT NULL AND expires_at <= %s AND expires_at > NOW() AND status = %s",
(new \DateTimeImmutable())->modify('+30 days')->format('Y-m-d H:i:s'),
License::STATUS_ACTIVE
)
);
// Get licenses by product
$byProduct = $wpdb->get_results(
"SELECT product_id, COUNT(*) as count FROM {$tableName} GROUP BY product_id ORDER BY count DESC LIMIT 10",
ARRAY_A
);
$productStats = [];
foreach ($byProduct ?: [] as $row) {
$product = wc_get_product((int) $row['product_id']);
$productStats[] = [
'product_id' => (int) $row['product_id'],
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
'count' => (int) $row['count'],
];
}
// Get licenses created per month (last 12 months)
$monthlyData = $wpdb->get_results(
"SELECT DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count
FROM {$tableName}
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month ASC",
ARRAY_A
);
$monthlyStats = [];
foreach ($monthlyData ?: [] as $row) {
$monthlyStats[$row['month']] = (int) $row['count'];
}
// Get top domains
$topDomains = $wpdb->get_results(
"SELECT domain, COUNT(*) as count FROM {$tableName} GROUP BY domain ORDER BY count DESC LIMIT 10",
ARRAY_A
);
return [
'total' => $total,
'by_status' => $byStatus,
'lifetime' => $lifetimeCount,
'expiring' => $expiringCount,
'expiring_soon' => $expiringSoon,
'by_product' => $productStats,
'monthly' => $monthlyStats,
'top_domains' => $topDomains ?: [],
];
}
/**
* Get licenses expiring within specified days
*
* @param int $days Number of days to look ahead
* @param bool $excludeNotified Whether to exclude already notified licenses
* @return array Array of License objects with customer data
*/
public function getLicensesExpiringSoon(int $days = 7, bool $excludeNotified = true): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$now = new \DateTimeImmutable();
$future = $now->modify("+{$days} days");
$sql = "SELECT * FROM {$tableName}
WHERE expires_at IS NOT NULL
AND expires_at > %s
AND expires_at <= %s
AND status = %s";
$params = [
$now->format('Y-m-d H:i:s'),
$future->format('Y-m-d H:i:s'),
License::STATUS_ACTIVE,
];
$rows = $wpdb->get_results(
$wpdb->prepare($sql, $params),
ARRAY_A
);
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
}
/**
* Mark license as notified for expiration warning
*
* @param int $licenseId License ID
* @param string $notificationType Type of notification (e.g., 'expiring_7_days', 'expiring_1_day')
* @return bool Success
*/
public function markExpirationNotified(int $licenseId, string $notificationType): bool
{
$metaKey = '_wclp_expiration_notified_' . sanitize_key($notificationType);
update_user_meta($this->getLicenseById($licenseId)?->getCustomerId() ?? 0, $metaKey . '_' . $licenseId, current_time('mysql'));
return true;
}
/**
* Check if license was already notified for expiration
*
* @param int $licenseId License ID
* @param string $notificationType Type of notification
* @return bool Whether already notified
*/
public function wasExpirationNotified(int $licenseId, string $notificationType): bool
{
$license = $this->getLicenseById($licenseId);
if (!$license) {
return true; // Consider notified if license doesn't exist
}
$metaKey = '_wclp_expiration_notified_' . sanitize_key($notificationType) . '_' . $licenseId;
return (bool) get_user_meta($license->getCustomerId(), $metaKey, true);
}
/**
* Import a license from CSV data
*
* @param string $licenseKey License key
* @param int $productId Product ID
* @param int $customerId Customer ID
* @param string $domain Domain name
* @param int $orderId Order ID (optional)
* @param string $status License status
* @param int $maxActivations Maximum activations
* @param int $activationsCount Current activation count
* @param \DateTimeImmutable|null $expiresAt Expiration date or null for lifetime
* @return bool Success
*/
public function importLicense(
string $licenseKey,
int $productId,
int $customerId,
string $domain,
int $orderId = 0,
string $status = License::STATUS_ACTIVE,
int $maxActivations = 1,
int $activationsCount = 1,
?\DateTimeImmutable $expiresAt = null
): bool {
global $wpdb;
$tableName = Installer::getLicensesTable();
$result = $wpdb->insert(
$tableName,
[
'license_key' => $licenseKey,
'order_id' => $orderId,
'product_id' => $productId,
'customer_id' => $customerId,
'domain' => $this->normalizeDomain($domain),
'version_id' => null,
'status' => $status,
'activations_count' => $activationsCount,
'max_activations' => $maxActivations,
'expires_at' => $expiresAt ? $expiresAt->format('Y-m-d H:i:s') : null,
],
['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s']
);
return $result !== false;
}
/**
* Export all licenses to array format suitable for CSV
*
* @return array Array of license data for CSV export
*/
public function exportLicensesForCsv(): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$rows = $wpdb->get_results(
"SELECT * FROM {$tableName} ORDER BY created_at DESC",
ARRAY_A
);
$exportData = [];
foreach ($rows ?: [] as $row) {
$product = wc_get_product((int) $row['product_id']);
$customer = get_userdata((int) $row['customer_id']);
$order = wc_get_order((int) $row['order_id']);
$exportData[] = [
'ID' => $row['id'],
'License Key' => $row['license_key'],
'Product' => $product ? $product->get_name() : 'Unknown',
'Product ID' => $row['product_id'],
'Order ID' => $row['order_id'],
'Order Number' => $order ? $order->get_order_number() : '',
'Customer' => $customer ? $customer->display_name : 'Guest',
'Customer Email' => $customer ? $customer->user_email : '',
'Customer ID' => $row['customer_id'],
'Domain' => $row['domain'],
'Status' => ucfirst($row['status']),
'Activations' => $row['activations_count'],
'Max Activations' => $row['max_activations'],
'Expires At' => $row['expires_at'] ?: 'Lifetime',
'Created At' => $row['created_at'],
'Updated At' => $row['updated_at'],
];
}
return $exportData;
}
} }

View File

@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct; namespace Jeremias\WcLicensedProduct;
use Jeremias\WcLicensedProduct\Admin\AdminController; use Jeremias\WcLicensedProduct\Admin\AdminController;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Admin\VersionAdminController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
use Jeremias\WcLicensedProduct\Api\RestApiController; use Jeremias\WcLicensedProduct\Api\RestApiController;
use Jeremias\WcLicensedProduct\Checkout\CheckoutController; use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
@@ -117,6 +118,7 @@ final class Plugin
if (is_admin()) { if (is_admin()) {
new AdminController($this->twig, $this->licenseManager); new AdminController($this->twig, $this->licenseManager);
new VersionAdminController($this->versionManager); new VersionAdminController($this->versionManager);
new SettingsController();
} }
} }

View File

@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product; namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use WC_Product; use WC_Product;
/** /**
@@ -55,28 +56,68 @@ class LicensedProduct extends WC_Product
/** /**
* Get max activations for this product * Get max activations for this product
* Falls back to default settings if not set on product
*/ */
public function get_max_activations(): int public function get_max_activations(): int
{ {
$value = $this->get_meta('_licensed_max_activations', true); $value = $this->get_meta('_licensed_max_activations', true);
return $value ? (int) $value : 1; if ($value !== '' && $value !== null) {
return max(1, (int) $value);
}
return SettingsController::getDefaultMaxActivations();
}
/**
* Check if product has custom max activations set
*/
public function has_custom_max_activations(): bool
{
$value = $this->get_meta('_licensed_max_activations', true);
return $value !== '' && $value !== null;
} }
/** /**
* Get validity days * Get validity days
* Falls back to default settings if not set on product
*/ */
public function get_validity_days(): ?int public function get_validity_days(): ?int
{ {
$value = $this->get_meta('_licensed_validity_days', true); $value = $this->get_meta('_licensed_validity_days', true);
return $value !== '' ? (int) $value : null; if ($value !== '' && $value !== null) {
return (int) $value > 0 ? (int) $value : null;
}
return SettingsController::getDefaultValidityDays();
}
/**
* Check if product has custom validity days set
*/
public function has_custom_validity_days(): bool
{
$value = $this->get_meta('_licensed_validity_days', true);
return $value !== '' && $value !== null;
} }
/** /**
* Check if license should be bound to major version * Check if license should be bound to major version
* Falls back to default settings if not set on product
*/ */
public function is_bound_to_version(): bool public function is_bound_to_version(): bool
{ {
return $this->get_meta('_licensed_bind_to_version', true) === 'yes'; $value = $this->get_meta('_licensed_bind_to_version', true);
if ($value !== '' && $value !== null) {
return $value === 'yes';
}
return SettingsController::getDefaultBindToVersion();
}
/**
* Check if product has custom bind to version setting
*/
public function has_custom_bind_to_version(): bool
{
$value = $this->get_meta('_licensed_bind_to_version', true);
return $value !== '' && $value !== null;
} }
/** /**

View File

@@ -9,6 +9,8 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product; namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
/** /**
* Registers and handles the Licensed product type for WooCommerce * Registers and handles the Licensed product type for WooCommerce
*/ */
@@ -85,39 +87,82 @@ final class LicensedProductType
public function addProductDataPanel(): void public function addProductDataPanel(): void
{ {
global $post; global $post;
// Get current product values
$currentMaxActivations = get_post_meta($post->ID, '_licensed_max_activations', true);
$currentValidityDays = get_post_meta($post->ID, '_licensed_validity_days', true);
$currentBindToVersion = get_post_meta($post->ID, '_licensed_bind_to_version', true);
// Get default values
$defaultMaxActivations = SettingsController::getDefaultMaxActivations();
$defaultValidityDays = SettingsController::getDefaultValidityDays();
$defaultBindToVersion = SettingsController::getDefaultBindToVersion();
// Format default validity for display
$defaultValidityDisplay = $defaultValidityDays !== null
? sprintf(__('%d days', 'wc-licensed-product'), $defaultValidityDays)
: __('Lifetime', 'wc-licensed-product');
?> ?>
<div id="licensed_product_data" class="panel woocommerce_options_panel hidden"> <div id="licensed_product_data" class="panel woocommerce_options_panel hidden">
<div class="options_group"> <div class="options_group">
<p class="form-field">
<em><?php
printf(
/* translators: %s: URL to settings page */
esc_html__('Leave fields empty to use default settings from %s.', 'wc-licensed-product'),
'<a href="' . esc_url(admin_url('admin.php?page=wc-settings&tab=licensed_product')) . '">' .
esc_html__('WooCommerce > Settings > Licensed Products', 'wc-licensed-product') . '</a>'
);
?></em>
</p>
<?php <?php
woocommerce_wp_text_input([ woocommerce_wp_text_input([
'id' => '_licensed_max_activations', 'id' => '_licensed_max_activations',
'label' => __('Max Activations', 'wc-licensed-product'), 'label' => __('Max Activations', 'wc-licensed-product'),
'description' => __('Maximum number of domain activations per license. Default: 1', 'wc-licensed-product'), 'description' => sprintf(
/* translators: %d: default max activations value */
__('Maximum number of domain activations per license. Default: %d', 'wc-licensed-product'),
$defaultMaxActivations
),
'desc_tip' => true, 'desc_tip' => true,
'type' => 'number', 'type' => 'number',
'custom_attributes' => [ 'custom_attributes' => [
'min' => '1', 'min' => '1',
'step' => '1', 'step' => '1',
], ],
'value' => get_post_meta($post->ID, '_licensed_max_activations', true) ?: '1', 'placeholder' => (string) $defaultMaxActivations,
'value' => $currentMaxActivations,
]); ]);
woocommerce_wp_text_input([ woocommerce_wp_text_input([
'id' => '_licensed_validity_days', 'id' => '_licensed_validity_days',
'label' => __('License Validity (Days)', 'wc-licensed-product'), 'label' => __('License Validity (Days)', 'wc-licensed-product'),
'description' => __('Number of days the license is valid. Leave empty for lifetime license.', 'wc-licensed-product'), 'description' => sprintf(
/* translators: %s: default validity value */
__('Number of days the license is valid. Leave empty for default (%s).', 'wc-licensed-product'),
$defaultValidityDisplay
),
'desc_tip' => true, 'desc_tip' => true,
'type' => 'number', 'type' => 'number',
'custom_attributes' => [ 'custom_attributes' => [
'min' => '0', 'min' => '0',
'step' => '1', 'step' => '1',
], ],
'placeholder' => $defaultValidityDays !== null ? (string) $defaultValidityDays : __('Lifetime', 'wc-licensed-product'),
'value' => $currentValidityDays,
]); ]);
woocommerce_wp_checkbox([ woocommerce_wp_checkbox([
'id' => '_licensed_bind_to_version', 'id' => '_licensed_bind_to_version',
'label' => __('Bind to Major Version', 'wc-licensed-product'), 'label' => __('Bind to Major Version', 'wc-licensed-product'),
'description' => __('If enabled, licenses are bound to the major version at purchase time.', 'wc-licensed-product'), 'description' => sprintf(
/* translators: %s: default bind to version value (Yes/No) */
__('If enabled, licenses are bound to the major version at purchase time. Default: %s', 'wc-licensed-product'),
$defaultBindToVersion ? __('Yes', 'wc-licensed-product') : __('No', 'wc-licensed-product')
),
'value' => $currentBindToVersion ?: ($defaultBindToVersion ? 'yes' : 'no'),
'cbvalue' => 'yes',
]); ]);
woocommerce_wp_text_input([ woocommerce_wp_text_input([
@@ -160,19 +205,26 @@ final class LicensedProductType
public function saveProductMeta(int $postId): void public function saveProductMeta(int $postId): void
{ {
// Verify nonce is handled by WooCommerce // Verify nonce is handled by WooCommerce
$maxActivations = isset($_POST['_licensed_max_activations']) // Allow empty values to fall back to defaults
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified by WooCommerce
$maxActivations = isset($_POST['_licensed_max_activations']) && $_POST['_licensed_max_activations'] !== ''
? absint($_POST['_licensed_max_activations']) ? absint($_POST['_licensed_max_activations'])
: 1; : '';
update_post_meta($postId, '_licensed_max_activations', $maxActivations); update_post_meta($postId, '_licensed_max_activations', $maxActivations);
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$validityDays = isset($_POST['_licensed_validity_days']) && $_POST['_licensed_validity_days'] !== '' $validityDays = isset($_POST['_licensed_validity_days']) && $_POST['_licensed_validity_days'] !== ''
? absint($_POST['_licensed_validity_days']) ? absint($_POST['_licensed_validity_days'])
: ''; : '';
update_post_meta($postId, '_licensed_validity_days', $validityDays); update_post_meta($postId, '_licensed_validity_days', $validityDays);
// For checkbox, we need to distinguish between "not set" and "explicitly unchecked"
// If the hidden field is present, the form was submitted and we save the actual value
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$bindToVersion = isset($_POST['_licensed_bind_to_version']) ? 'yes' : 'no'; $bindToVersion = isset($_POST['_licensed_bind_to_version']) ? 'yes' : 'no';
update_post_meta($postId, '_licensed_bind_to_version', $bindToVersion); update_post_meta($postId, '_licensed_bind_to_version', $bindToVersion);
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$currentVersion = isset($_POST['_licensed_current_version']) $currentVersion = isset($_POST['_licensed_current_version'])
? sanitize_text_field($_POST['_licensed_current_version']) ? sanitize_text_field($_POST['_licensed_current_version'])
: ''; : '';

View File

@@ -0,0 +1,176 @@
<div class="wrap wclp-dashboard">
<h1>{{ __('License Dashboard') }}</h1>
<div class="wclp-dashboard-stats">
<!-- Overview Cards -->
<div class="wclp-stat-cards">
<div class="wclp-stat-card wclp-stat-total">
<div class="wclp-stat-icon"><span class="dashicons dashicons-admin-network"></span></div>
<div class="wclp-stat-content">
<span class="wclp-stat-number">{{ stats.total }}</span>
<span class="wclp-stat-label">{{ __('Total Licenses') }}</span>
</div>
</div>
<div class="wclp-stat-card wclp-stat-active">
<div class="wclp-stat-icon"><span class="dashicons dashicons-yes-alt"></span></div>
<div class="wclp-stat-content">
<span class="wclp-stat-number">{{ stats.by_status.active }}</span>
<span class="wclp-stat-label">{{ __('Active') }}</span>
</div>
</div>
<div class="wclp-stat-card wclp-stat-inactive">
<div class="wclp-stat-icon"><span class="dashicons dashicons-marker"></span></div>
<div class="wclp-stat-content">
<span class="wclp-stat-number">{{ stats.by_status.inactive }}</span>
<span class="wclp-stat-label">{{ __('Inactive') }}</span>
</div>
</div>
<div class="wclp-stat-card wclp-stat-expired">
<div class="wclp-stat-icon"><span class="dashicons dashicons-calendar-alt"></span></div>
<div class="wclp-stat-content">
<span class="wclp-stat-number">{{ stats.by_status.expired }}</span>
<span class="wclp-stat-label">{{ __('Expired') }}</span>
</div>
</div>
<div class="wclp-stat-card wclp-stat-revoked">
<div class="wclp-stat-icon"><span class="dashicons dashicons-dismiss"></span></div>
<div class="wclp-stat-content">
<span class="wclp-stat-number">{{ stats.by_status.revoked }}</span>
<span class="wclp-stat-label">{{ __('Revoked') }}</span>
</div>
</div>
</div>
<!-- Alert: Expiring Soon -->
{% if stats.expiring_soon > 0 %}
<div class="notice notice-warning">
<p>
<span class="dashicons dashicons-warning"></span>
<strong>{{ __('Attention:') }}</strong>
{{ stats.expiring_soon }} {{ stats.expiring_soon == 1 ? __('license is') : __('licenses are') }}
{{ __('expiring within the next 30 days.') }}
<a href="{{ admin_url }}?page=wc-licenses">{{ __('View Licenses') }}</a>
</p>
</div>
{% endif %}
<!-- Secondary Stats -->
<div class="wclp-stat-row">
<div class="wclp-stat-box">
<h3>{{ __('License Types') }}</h3>
<table class="widefat striped">
<tbody>
<tr>
<td>{{ __('Lifetime Licenses') }}</td>
<td class="wclp-stat-value">{{ stats.lifetime }}</td>
</tr>
<tr>
<td>{{ __('Time-limited Licenses') }}</td>
<td class="wclp-stat-value">{{ stats.expiring }}</td>
</tr>
<tr>
<td>{{ __('Expiring Soon (30 days)') }}</td>
<td class="wclp-stat-value {% if stats.expiring_soon > 0 %}wclp-warning{% endif %}">
{{ stats.expiring_soon }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="wclp-stat-box">
<h3>{{ __('Top Products by Licenses') }}</h3>
{% if stats.by_product is empty %}
<p class="description">{{ __('No license data available yet.') }}</p>
{% else %}
<table class="widefat striped">
<thead>
<tr>
<th>{{ __('Product') }}</th>
<th class="wclp-stat-value">{{ __('Licenses') }}</th>
</tr>
</thead>
<tbody>
{% for product in stats.by_product %}
<tr>
<td>{{ esc_html(product.product_name) }}</td>
<td class="wclp-stat-value">{{ product.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<div class="wclp-stat-box">
<h3>{{ __('Top Domains') }}</h3>
{% if stats.top_domains is empty %}
<p class="description">{{ __('No license data available yet.') }}</p>
{% else %}
<table class="widefat striped">
<thead>
<tr>
<th>{{ __('Domain') }}</th>
<th class="wclp-stat-value">{{ __('Licenses') }}</th>
</tr>
</thead>
<tbody>
{% for domain in stats.top_domains %}
<tr>
<td><code>{{ esc_html(domain.domain) }}</code></td>
<td class="wclp-stat-value">{{ domain.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<!-- Monthly Chart -->
<div class="wclp-stat-box wclp-stat-full">
<h3>{{ __('Licenses Created (Last 12 Months)') }}</h3>
{% if stats.monthly is empty %}
<p class="description">{{ __('No license data available yet.') }}</p>
{% else %}
<div class="wclp-chart-container">
<div class="wclp-bar-chart" id="wclp-monthly-chart">
{% set max_value = 1 %}
{% for count in stats.monthly %}
{% if count > max_value %}
{% set max_value = count %}
{% endif %}
{% endfor %}
{% for month, count in stats.monthly %}
<div class="wclp-bar-wrapper">
<div class="wclp-bar" style="height: {{ (count / max_value * 100)|round }}%;" title="{{ count }} {{ __('licenses') }}">
<span class="wclp-bar-value">{{ count }}</span>
</div>
<span class="wclp-bar-label">{{ month|date('M Y') }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Quick Actions -->
<div class="wclp-dashboard-actions">
<h2>{{ __('Quick Actions') }}</h2>
<div class="wclp-action-buttons">
<a href="{{ admin_url }}?page=wc-licenses" class="button button-primary">
<span class="dashicons dashicons-admin-network"></span>
{{ __('Manage Licenses') }}
</a>
<a href="{{ admin_url }}?page=wc-licenses&action=export_csv" class="button">
<span class="dashicons dashicons-download"></span>
{{ __('Export to CSV') }}
</a>
<a href="{{ admin_url }}?page=wc-settings&tab=licensed_product" class="button">
<span class="dashicons dashicons-admin-generic"></span>
{{ __('Settings') }}
</a>
</div>
</div>
</div>

View File

@@ -1,5 +1,14 @@
<div class="wrap"> <div class="wrap">
<h1>{{ __('Licenses') }}</h1> <h1 class="wp-heading-inline">{{ __('Licenses') }}</h1>
<a href="{{ admin_url }}?action=export_csv" class="page-title-action">
<span class="dashicons dashicons-download" style="vertical-align: middle;"></span>
{{ __('Export CSV') }}
</a>
<a href="{{ admin_url }}?action=import_csv" class="page-title-action">
<span class="dashicons dashicons-upload" style="vertical-align: middle;"></span>
{{ __('Import CSV') }}
</a>
<hr class="wp-header-end">
{% for notice in notices %} {% for notice in notices %}
<div class="notice notice-{{ notice.type }} is-dismissible"> <div class="notice notice-{{ notice.type }} is-dismissible">
@@ -7,13 +16,80 @@
</div> </div>
{% endfor %} {% endfor %}
<p class="description"> <!-- Search and Filter Form -->
{{ __('Total licenses:') }} <strong>{{ total_licenses }}</strong> <form method="get" action="" class="wclp-filter-form">
<input type="hidden" name="page" value="wc-licenses">
<p class="search-box">
<label class="screen-reader-text" for="license-search-input">{{ __('Search Licenses') }}</label>
<input type="search" id="license-search-input" name="s" value="{{ filters.search|default('') }}"
placeholder="{{ __('Search license key or domain...') }}">
<input type="submit" id="search-submit" class="button" value="{{ __('Search') }}">
</p> </p>
<table class="wp-list-table widefat fixed striped"> <div class="tablenav top">
<div class="alignleft actions">
<select name="status">
<option value="all">{{ __('All Statuses') }}</option>
<option value="active" {{ filters.status|default('') == 'active' ? 'selected' : '' }}>{{ __('Active') }}</option>
<option value="inactive" {{ filters.status|default('') == 'inactive' ? 'selected' : '' }}>{{ __('Inactive') }}</option>
<option value="expired" {{ filters.status|default('') == 'expired' ? 'selected' : '' }}>{{ __('Expired') }}</option>
<option value="revoked" {{ filters.status|default('') == 'revoked' ? 'selected' : '' }}>{{ __('Revoked') }}</option>
</select>
<select name="product_id">
<option value="">{{ __('All Products') }}</option>
{% for id, name in products %}
<option value="{{ id }}" {{ filters.product_id|default('') == id ? 'selected' : '' }}>{{ esc_html(name) }}</option>
{% endfor %}
</select>
<input type="submit" class="button" value="{{ __('Filter') }}">
{% if filters is not empty %}
<a href="{{ admin_url }}" class="button">{{ __('Clear') }}</a>
{% endif %}
</div>
<div class="tablenav-pages">
<span class="displaying-num">{{ total_licenses }} {{ total_licenses == 1 ? __('item') : __('items') }}</span>
</div>
</div>
</form>
<p class="description">
{{ __('Showing') }} {{ total_licenses }} {{ total_licenses == 1 ? __('license') : __('licenses') }}
{% if filters is not empty %}
({{ __('filtered') }})
{% endif %}
| <a href="{{ constant('admin_url')('admin.php?page=wc-reports&tab=licenses') }}">{{ __('View Dashboard') }}</a>
</p>
<form method="post" action="{{ admin_url }}" id="licenses-bulk-form">
{{ wp_nonce_field('bulk_license_action', '_wpnonce', true, false)|raw }}
<div class="tablenav top">
<div class="alignleft actions bulkactions">
<select name="bulk_action" id="bulk-action-selector">
<option value="">{{ __('Bulk Actions') }}</option>
<option value="activate">{{ __('Activate') }}</option>
<option value="deactivate">{{ __('Deactivate') }}</option>
<option value="revoke">{{ __('Revoke') }}</option>
<option value="extend_30">{{ __('Extend 30 days') }}</option>
<option value="extend_90">{{ __('Extend 90 days') }}</option>
<option value="extend_365">{{ __('Extend 1 year') }}</option>
<option value="delete">{{ __('Delete') }}</option>
</select>
<input type="submit" class="button action" value="{{ __('Apply') }}">
</div>
</div>
<table class="wp-list-table widefat fixed striped licenses-table">
<thead> <thead>
<tr> <tr>
<td class="manage-column column-cb check-column">
<input type="checkbox" id="cb-select-all-1">
</td>
<th>{{ __('License Key') }}</th> <th>{{ __('License Key') }}</th>
<th>{{ __('Product') }}</th> <th>{{ __('Product') }}</th>
<th>{{ __('Customer') }}</th> <th>{{ __('Customer') }}</th>
@@ -26,11 +102,14 @@
<tbody> <tbody>
{% if licenses is empty %} {% if licenses is empty %}
<tr> <tr>
<td colspan="7">{{ __('No licenses found.') }}</td> <td colspan="8">{{ __('No licenses found.') }}</td>
</tr> </tr>
{% else %} {% else %}
{% for item in licenses %} {% for item in licenses %}
<tr> <tr>
<th scope="row" class="check-column">
<input type="checkbox" name="license_ids[]" value="{{ item.license.id }}">
</th>
<td><code>{{ item.license.licenseKey }}</code></td> <td><code>{{ item.license.licenseKey }}</code></td>
<td> <td>
{% if item.product_edit_url %} {% if item.product_edit_url %}
@@ -55,35 +134,80 @@
{% if item.license.expiresAt %} {% if item.license.expiresAt %}
{{ item.license.expiresAt|date('Y-m-d') }} {{ item.license.expiresAt|date('Y-m-d') }}
{% else %} {% else %}
{{ __('Never') }} <span class="license-lifetime">{{ __('Lifetime') }}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td class="license-actions">
<div class="row-actions">
{% if item.license.status != 'revoked' %} {% if item.license.status != 'revoked' %}
<a href="{{ admin_url ~ '?page=wc-licenses&action=revoke&license_id=' ~ item.license.id ~ '&_wpnonce=' }}" <span class="transfer">
class="button button-small" <a href="#" class="wclp-transfer-link"
onclick="return confirm('{{ __('Are you sure?') }}')"> data-license-id="{{ item.license.id }}"
{{ __('Revoke') }} data-current-domain="{{ esc_attr(item.license.domain) }}"
</a> title="{{ __('Transfer to new domain') }}">{{ __('Transfer') }}</a> |
</span>
<span class="extend">
<a href="{{ extend_url(item.license.id, 30) }}" title="{{ __('Extend by 30 days') }}">+30d</a> |
</span>
<span class="lifetime">
<a href="{{ lifetime_url(item.license.id) }}" title="{{ __('Set to lifetime') }}">∞</a> |
</span>
<span class="revoke">
<a href="{{ revoke_url(item.license.id) }}"
onclick="return confirm('{{ __('Are you sure?') }}')">{{ __('Revoke') }}</a> |
</span>
{% endif %} {% endif %}
<a href="{{ admin_url ~ '?page=wc-licenses&action=delete&license_id=' ~ item.license.id ~ '&_wpnonce=' }}" <span class="delete">
class="button button-small button-link-delete" <a href="{{ delete_url(item.license.id) }}"
onclick="return confirm('{{ __('Are you sure you want to delete this license?') }}')"> class="submitdelete"
{{ __('Delete') }} onclick="return confirm('{{ __('Are you sure you want to delete this license?') }}')">{{ __('Delete') }}</a>
</a> </span>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</tbody> </tbody>
<tfoot>
<tr>
<td class="manage-column column-cb check-column">
<input type="checkbox" id="cb-select-all-2">
</td>
<th>{{ __('License Key') }}</th>
<th>{{ __('Product') }}</th>
<th>{{ __('Customer') }}</th>
<th>{{ __('Domain') }}</th>
<th>{{ __('Status') }}</th>
<th>{{ __('Expires') }}</th>
<th>{{ __('Actions') }}</th>
</tr>
</tfoot>
</table> </table>
{% if total_pages > 1 %}
<div class="tablenav bottom"> <div class="tablenav bottom">
<div class="alignleft actions bulkactions">
<select name="bulk_action_2" id="bulk-action-selector-bottom">
<option value="">{{ __('Bulk Actions') }}</option>
<option value="activate">{{ __('Activate') }}</option>
<option value="deactivate">{{ __('Deactivate') }}</option>
<option value="revoke">{{ __('Revoke') }}</option>
<option value="extend_30">{{ __('Extend 30 days') }}</option>
<option value="extend_90">{{ __('Extend 90 days') }}</option>
<option value="extend_365">{{ __('Extend 1 year') }}</option>
<option value="delete">{{ __('Delete') }}</option>
</select>
<input type="submit" class="button action" value="{{ __('Apply') }}">
</div>
{% if total_pages > 1 %}
{% set filter_params = '' %}
{% if filters.search is defined and filters.search %}{% set filter_params = filter_params ~ '&s=' ~ filters.search %}{% endif %}
{% if filters.status is defined and filters.status %}{% set filter_params = filter_params ~ '&status=' ~ filters.status %}{% endif %}
{% if filters.product_id is defined and filters.product_id %}{% set filter_params = filter_params ~ '&product_id=' ~ filters.product_id %}{% endif %}
<div class="tablenav-pages"> <div class="tablenav-pages">
<span class="pagination-links"> <span class="pagination-links">
{% if current_page > 1 %} {% if current_page > 1 %}
<a class="prev-page button" href="{{ admin_url ~ '&paged=' ~ (current_page - 1) }}"> <a class="prev-page button" href="{{ admin_url ~ '&paged=' ~ (current_page - 1) ~ filter_params }}">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </a>
{% endif %} {% endif %}
@@ -91,12 +215,93 @@
{{ current_page }} {{ __('of') }} {{ total_pages }} {{ current_page }} {{ __('of') }} {{ total_pages }}
</span> </span>
{% if current_page < total_pages %} {% if current_page < total_pages %}
<a class="next-page button" href="{{ admin_url ~ '&paged=' ~ (current_page + 1) }}"> <a class="next-page button" href="{{ admin_url ~ '&paged=' ~ (current_page + 1) ~ filter_params }}">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </a>
{% endif %} {% endif %}
</span> </span>
</div> </div>
</div>
{% endif %} {% endif %}
</div>
</form>
</div> </div>
<!-- Transfer Modal -->
<div id="wclp-transfer-modal" class="wclp-modal" style="display:none;">
<div class="wclp-modal-content">
<span class="wclp-modal-close">&times;</span>
<h2>{{ __('Transfer License to New Domain') }}</h2>
<form method="post" action="{{ admin_url }}">
<input type="hidden" name="action" value="transfer_license">
<input type="hidden" name="_wpnonce" value="{{ transfer_nonce() }}">
<input type="hidden" name="license_id" id="transfer-license-id" value="">
<table class="form-table">
<tr>
<th scope="row"><label>{{ __('Current Domain') }}</label></th>
<td><code id="transfer-current-domain"></code></td>
</tr>
<tr>
<th scope="row"><label for="new_domain">{{ __('New Domain') }}</label></th>
<td>
<input type="text" name="new_domain" id="transfer-new-domain" class="regular-text"
placeholder="example.com" required>
<p class="description">{{ __('Enter the new domain without http:// or www.') }}</p>
</td>
</tr>
</table>
<p class="submit">
<button type="submit" class="button button-primary">{{ __('Transfer License') }}</button>
<button type="button" class="button wclp-modal-cancel">{{ __('Cancel') }}</button>
</p>
</form>
</div>
</div>
<script>
(function($) {
// Select all checkboxes
$('#cb-select-all-1, #cb-select-all-2').on('change', function() {
$('input[name="license_ids[]"]').prop('checked', this.checked);
$('#cb-select-all-1, #cb-select-all-2').prop('checked', this.checked);
});
// Sync bulk action selects
$('#bulk-action-selector, #bulk-action-selector-bottom').on('change', function() {
var value = $(this).val();
$('#bulk-action-selector, #bulk-action-selector-bottom').val(value);
});
// Use bottom select value if top is empty
$('form').on('submit', function() {
var topAction = $('#bulk-action-selector').val();
var bottomAction = $('#bulk-action-selector-bottom').val();
if (!topAction && bottomAction) {
$('#bulk-action-selector').val(bottomAction);
}
});
// Transfer modal
var $modal = $('#wclp-transfer-modal');
$('.wclp-transfer-link').on('click', function(e) {
e.preventDefault();
var licenseId = $(this).data('license-id');
var currentDomain = $(this).data('current-domain');
$('#transfer-license-id').val(licenseId);
$('#transfer-current-domain').text(currentDomain);
$('#transfer-new-domain').val('');
$modal.show();
});
$('.wclp-modal-close, .wclp-modal-cancel').on('click', function() {
$modal.hide();
});
$(window).on('click', function(e) {
if ($(e.target).is($modal)) {
$modal.hide();
}
});
})(jQuery);
</script>

View File

@@ -29,7 +29,19 @@
</div> </div>
<div class="license-info-row"> <div class="license-info-row">
<span><strong>{{ __('Domain:') }}</strong> {{ esc_html(item.license.domain) }}</span> <span class="license-domain-display" data-license-id="{{ item.license.id }}">
<strong>{{ __('Domain:') }}</strong>
<span class="domain-value">{{ esc_html(item.license.domain) }}</span>
{% if item.license.status == 'active' or item.license.status == 'inactive' %}
<button type="button" class="wclp-transfer-btn"
data-license-id="{{ item.license.id }}"
data-current-domain="{{ esc_attr(item.license.domain) }}"
title="{{ __('Transfer to new domain') }}">
<span class="dashicons dashicons-randomize"></span>
{{ __('Transfer') }}
</button>
{% endif %}
</span>
<span><strong>{{ __('Expires:') }}</strong> <span><strong>{{ __('Expires:') }}</strong>
{% if item.license.expiresAt %} {% if item.license.expiresAt %}
{{ item.license.expiresAt|date('Y-m-d') }} {{ item.license.expiresAt|date('Y-m-d') }}
@@ -60,4 +72,38 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Transfer Modal -->
<div id="wclp-transfer-modal" class="wclp-modal" style="display:none;">
<div class="wclp-modal-overlay"></div>
<div class="wclp-modal-content">
<button type="button" class="wclp-modal-close" aria-label="{{ __('Close') }}">&times;</button>
<h3>{{ __('Transfer License to New Domain') }}</h3>
<form id="wclp-transfer-form">
<input type="hidden" name="license_id" id="transfer-license-id" value="">
<div class="wclp-form-row">
<label>{{ __('Current Domain') }}</label>
<p class="wclp-current-domain"><code id="transfer-current-domain"></code></p>
</div>
<div class="wclp-form-row">
<label for="transfer-new-domain">{{ __('New Domain') }}</label>
<input type="text" name="new_domain" id="transfer-new-domain"
placeholder="example.com" required
pattern="[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+">
<p class="wclp-field-description">{{ __('Enter the new domain without http:// or www.') }}</p>
</div>
<div class="wclp-form-row wclp-form-actions">
<button type="submit" class="button wclp-btn-primary" id="wclp-transfer-submit">
{{ __('Transfer License') }}
</button>
<button type="button" class="button wclp-modal-cancel">{{ __('Cancel') }}</button>
</div>
<div id="wclp-transfer-message" class="wclp-message" style="display:none;"></div>
</form>
</div>
</div>
{% endif %} {% endif %}

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce Licensed Product * Plugin Name: WooCommerce Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-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. * Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
* Version: 0.0.3 * Version: 0.0.7
* Author: Marco Graetsch * Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev * Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
} }
// Plugin constants // Plugin constants
define('WC_LICENSED_PRODUCT_VERSION', '0.0.3'); define('WC_LICENSED_PRODUCT_VERSION', '0.0.7');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__); define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));