From 404083f023d4b66c5d3ace7eacd816511c7029ff Mon Sep 17 00:00:00 2001 From: magdev Date: Wed, 21 Jan 2026 18:55:18 +0100 Subject: [PATCH] Implement version 0.0.1 - Licensed Product type for WooCommerce Add complete plugin infrastructure for selling software with license keys: - New "Licensed Product" WooCommerce product type - License key generation (XXXX-XXXX-XXXX-XXXX format) on order completion - Domain-based license validation system - REST API endpoints (validate, status, activate, deactivate) - Customer My Account "Licenses" page - Admin license management under WooCommerce > Licenses - Checkout domain field for licensed products - Custom database tables for licenses and product versions - Twig template engine integration - Full i18n support with German (de_CH) translation Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + CHANGELOG.md | 48 +++ README.md | 121 ++++++++ assets/css/admin.css | 91 ++++++ assets/css/frontend.css | 124 ++++++++ composer.json | 31 ++ composer.lock | 338 ++++++++++++++++++++ languages/wc-licensed-product-de_CH.po | 256 +++++++++++++++ languages/wc-licensed-product.pot | 253 +++++++++++++++ src/Admin/AdminController.php | 412 +++++++++++++++++++++++++ src/Api/RestApiController.php | 271 ++++++++++++++++ src/Checkout/CheckoutController.php | 207 +++++++++++++ src/Frontend/AccountController.php | 187 +++++++++++ src/Installer.php | 138 +++++++++ src/License/License.php | 178 +++++++++++ src/License/LicenseManager.php | 363 ++++++++++++++++++++++ src/Plugin.php | 163 ++++++++++ src/Product/LicensedProduct.php | 103 +++++++ src/Product/LicensedProductType.php | 200 ++++++++++++ templates/admin/licenses.html.twig | 102 ++++++ templates/frontend/licenses.html.twig | 46 +++ wc-licensed-product.php | 113 +++++++ 22 files changed, 3746 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 assets/css/admin.css create mode 100644 assets/css/frontend.css create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 languages/wc-licensed-product-de_CH.po create mode 100644 languages/wc-licensed-product.pot create mode 100644 src/Admin/AdminController.php create mode 100644 src/Api/RestApiController.php create mode 100644 src/Checkout/CheckoutController.php create mode 100644 src/Frontend/AccountController.php create mode 100644 src/Installer.php create mode 100644 src/License/License.php create mode 100644 src/License/LicenseManager.php create mode 100644 src/Plugin.php create mode 100644 src/Product/LicensedProduct.php create mode 100644 src/Product/LicensedProductType.php create mode 100644 templates/admin/licenses.html.twig create mode 100644 templates/frontend/licenses.html.twig create mode 100644 wc-licensed-product.php diff --git a/.gitignore b/.gitignore index 3193733..718532b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ # Linked wordpress core and plugin folder wp-plugins wp-core +vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..78166f9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.0.1] - 2024-01-21 + +### Added + +- Initial plugin structure with WordPress Plugin API integration +- New WooCommerce product type "Licensed Product" for selling software licenses +- License key generation with format XXXX-XXXX-XXXX-XXXX on order completion +- Domain-based license validation (licenses bound to specific domains) +- REST API endpoints for license management: + - `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/activate` - Activate license on domain + - `POST /wp-json/wc-licensed-product/v1/deactivate` - Deactivate license +- Checkout domain field for licensed products +- Customer account page "Licenses" to view purchased licenses +- Admin interface for license management (WooCommerce > Licenses) +- License settings per product: + - Maximum activations per license + - License validity period (days or lifetime) + - Optional binding to major software version + - Current version tracking +- Custom database tables for licenses and product versions +- Twig template engine integration for views +- Full internationalization support (i18n) +- German (Switzerland, formal) translation (de_CH) +- WooCommerce HPOS compatibility +- Responsive frontend license table + +### Technical + +- PHP 8.3+ required +- WooCommerce 10.0+ required +- PSR-4 autoloading via Composer +- Twig 3.0 template engine +- WordPress REST API integration +- Custom WooCommerce product type extending WC_Product + +[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.1...HEAD +[0.0.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/releases/tag/v0.0.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e72684 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# WC Licensed Product + +A WooCommerce plugin to sell software products using license keys with domain-based validation. + +## Description + +WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, enabling you to sell software with automatically generated license keys. Licenses are bound to specific domains and can be validated through a REST API. + +## Features + +- **Licensed Product Type**: New WooCommerce product type for software sales +- **Automatic License Generation**: License keys generated on order completion +- **Domain Binding**: Licenses are bound to customer-specified domains +- **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 +- **Expiration Support**: Set license validity periods or lifetime licenses + +## Requirements + +- WordPress 6.0 or higher +- WooCommerce 10.0 or higher +- PHP 8.3 or higher + +## Installation + +1. Upload the `wc-licensed-product` folder to `/wp-content/plugins/` +2. Activate the plugin through the 'Plugins' menu in WordPress +3. The plugin will create necessary database tables on activation + +## Usage + +### Creating a Licensed Product + +1. Go to Products > Add New +2. Select "Licensed Product" from the product type dropdown +3. Configure the product price in the General tab +4. Set license options in the "License Settings" tab: + - **Max Activations**: Number of domains allowed per license + - **License Validity**: Days until expiration (empty = lifetime) + - **Bind to Major Version**: Lock license to current major version + - **Current Version**: Your software's current version + +### Customer Checkout + +When a customer purchases a licensed product, they must enter the domain where they will use the license during checkout. + +### Viewing Licenses + +- **Customers**: My Account > Licenses +- **Administrators**: WooCommerce > Licenses + +## REST API + +### Validate License + +```http +POST /wp-json/wc-licensed-product/v1/validate +Content-Type: application/json + +{ + "license_key": "XXXX-XXXX-XXXX-XXXX", + "domain": "example.com" +} +``` + +### Check Status + +```http +POST /wp-json/wc-licensed-product/v1/status +Content-Type: application/json + +{ + "license_key": "XXXX-XXXX-XXXX-XXXX" +} +``` + +### Activate License + +```http +POST /wp-json/wc-licensed-product/v1/activate +Content-Type: application/json + +{ + "license_key": "XXXX-XXXX-XXXX-XXXX", + "domain": "newdomain.com" +} +``` + +### Deactivate License + +```http +POST /wp-json/wc-licensed-product/v1/deactivate +Content-Type: application/json + +{ + "license_key": "XXXX-XXXX-XXXX-XXXX", + "domain": "example.com" +} +``` + +## License Statuses + +- **Active**: License is valid and usable +- **Inactive**: License has been deactivated +- **Expired**: License validity period has ended +- **Revoked**: License has been manually revoked by admin + +## Support + +For issues and feature requests, please visit: + + +## Author + +Marco Graetsch + +## License + +GPL-2.0-or-later diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..7008182 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,91 @@ +/** + * WC Licensed Product - Admin Styles + * + * @package Jeremias\WcLicensedProduct + */ + +/* License Status Badges */ +.license-status { + display: inline-block; + padding: 0.2em 0.5em; + font-size: 0.85em; + font-weight: 500; + line-height: 1.2; + border-radius: 3px; +} + +.license-status-active { + background-color: #d4edda; + color: #155724; +} + +.license-status-inactive { + background-color: #fff3cd; + color: #856404; +} + +.license-status-expired { + background-color: #f8d7da; + color: #721c24; +} + +.license-status-revoked { + background-color: #d6d8db; + color: #383d41; +} + +/* License Table */ +.wp-list-table code { + font-family: monospace; + background-color: #f0f0f0; + padding: 0.15em 0.4em; + border-radius: 3px; + font-size: 0.9em; +} + +/* License Product Tab */ +#woocommerce-product-data .show_if_licensed { + display: block !important; +} + +#woocommerce-product-data .hide_if_licensed { + display: none !important; +} + +/* Action Buttons */ +.wp-list-table .button-link-delete { + color: #a00; +} + +.wp-list-table .button-link-delete:hover { + color: #dc3232; +} + +/* Pagination */ +.tablenav-pages .pagination-links { + display: flex; + align-items: center; + gap: 0.5em; +} + +.tablenav-pages .paging-input { + margin: 0 0.5em; +} + +/* Orders Column */ +.column-license { + width: 15%; +} + +.column-license .dashicons { + vertical-align: middle; + margin-right: 0.3em; +} + +.column-license .dashicons-warning { + color: #dba617; +} + +.column-license .dashicons-admin-network { + color: #2271b1; +} diff --git a/assets/css/frontend.css b/assets/css/frontend.css new file mode 100644 index 0000000..884de01 --- /dev/null +++ b/assets/css/frontend.css @@ -0,0 +1,124 @@ +/** + * WC Licensed Product - Frontend Styles + * + * @package Jeremias\WcLicensedProduct + */ + +/* License Status Badges */ +.license-status { + display: inline-block; + padding: 0.25em 0.6em; + font-size: 0.85em; + font-weight: 600; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0.25rem; +} + +.license-status-active { + background-color: #d4edda; + color: #155724; +} + +.license-status-inactive { + background-color: #fff3cd; + color: #856404; +} + +.license-status-expired { + background-color: #f8d7da; + color: #721c24; +} + +.license-status-revoked { + background-color: #d6d8db; + color: #383d41; +} + +/* License Table */ +.woocommerce-licenses-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1em; +} + +.woocommerce-licenses-table th, +.woocommerce-licenses-table td { + padding: 0.75em; + text-align: left; + border-bottom: 1px solid #e5e5e5; +} + +.woocommerce-licenses-table th { + font-weight: 600; + background-color: #f8f8f8; +} + +.woocommerce-licenses-table code { + font-family: monospace; + background-color: #f5f5f5; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; +} + +/* Domain Field */ +#licensed-product-domain-field { + margin-top: 2em; + padding: 1.5em; + background-color: #f8f9fa; + border: 1px solid #e5e5e5; + border-radius: 4px; +} + +#licensed-product-domain-field h3 { + margin-top: 0; + margin-bottom: 1em; + font-size: 1.1em; +} + +#licensed-product-domain-field .description { + display: block; + margin-top: 0.5em; + font-size: 0.9em; + color: #666; +} + +/* Responsive */ +@media screen and (max-width: 768px) { + .woocommerce-licenses-table, + .woocommerce-licenses-table thead, + .woocommerce-licenses-table tbody, + .woocommerce-licenses-table th, + .woocommerce-licenses-table td, + .woocommerce-licenses-table tr { + display: block; + } + + .woocommerce-licenses-table thead tr { + position: absolute; + top: -9999px; + left: -9999px; + } + + .woocommerce-licenses-table tr { + border: 1px solid #e5e5e5; + margin-bottom: 1em; + } + + .woocommerce-licenses-table td { + border: none; + position: relative; + padding-left: 50%; + } + + .woocommerce-licenses-table td:before { + content: attr(data-title); + position: absolute; + left: 0.75em; + width: 45%; + font-weight: 600; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5ddf555 --- /dev/null +++ b/composer.json @@ -0,0 +1,31 @@ +{ + "name": "magdev/wc-licensed-product", + "description": "WooCommerce plugin to sell software products using license keys", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "Marco Graetsch", + "email": "magdev3.0@gmail.com", + "homepage": "https://src.bundespruefstelle.ch/magdev" + } + ], + "require": { + "php": ">=8.3.0", + "twig/twig": "^3.0" + }, + "autoload": { + "psr-4": { + "Jeremias\\WcLicensedProduct\\": "src/" + } + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true, + "platform": { + "php": "8.3.0" + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..d85e21b --- /dev/null +++ b/composer.lock @@ -0,0 +1,338 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "3b63b77b19677953867f471c141fee05", + "packages": [ + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "twig/twig", + "version": "v3.22.2", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2", + "reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.22.2" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2025-12-14T11:28:47+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.3.0" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.3.0" + }, + "plugin-api-version": "2.6.0" +} diff --git a/languages/wc-licensed-product-de_CH.po b/languages/wc-licensed-product-de_CH.po new file mode 100644 index 0000000..03903ab --- /dev/null +++ b/languages/wc-licensed-product-de_CH.po @@ -0,0 +1,256 @@ +# German (Switzerland, formal) translation for WC Licensed Product +# Copyright (C) 2024 Marco Graetsch +# This file is distributed under the GPL-2.0-or-later. +msgid "" +msgstr "" +"Project-Id-Version: WC Licensed Product 0.0.1\n" +"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/issues\n" +"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n" +"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n" +"Last-Translator: Marco Graetsch \n" +"Language-Team: German (Switzerland) \n" +"Language: de_CH\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: wc-licensed-product.php +msgid "%s requires WooCommerce to be installed and active." +msgstr "%s benötigt WooCommerce als installierte und aktivierte Erweiterung." + +#: wc-licensed-product.php +msgid "WC Licensed Product requires WooCommerce to be installed and active." +msgstr "WC Licensed Product benötigt WooCommerce als installierte und aktivierte Erweiterung." + +#: src/Product/LicensedProductType.php +msgid "Licensed Product" +msgstr "Lizenziertes Produkt" + +#: src/Product/LicensedProductType.php +msgid "License Settings" +msgstr "Lizenz-Einstellungen" + +#: src/Product/LicensedProductType.php +msgid "Max Activations" +msgstr "Max. Aktivierungen" + +#: src/Product/LicensedProductType.php +msgid "Maximum number of domain activations per license. Default: 1" +msgstr "Maximale Anzahl der Domain-Aktivierungen pro Lizenz. Standard: 1" + +#: src/Product/LicensedProductType.php +msgid "License Validity (Days)" +msgstr "Lizenz-Gültigkeit (Tage)" + +#: src/Product/LicensedProductType.php +msgid "Number of days the license is valid. Leave empty for lifetime license." +msgstr "Anzahl Tage, die die Lizenz gültig ist. Leer lassen für eine lebenslange Lizenz." + +#: src/Product/LicensedProductType.php +msgid "Bind to Major Version" +msgstr "An Hauptversion binden" + +#: src/Product/LicensedProductType.php +msgid "If enabled, licenses are bound to the major version at purchase time." +msgstr "Falls aktiviert, werden Lizenzen an die Hauptversion zum Kaufzeitpunkt gebunden." + +#: src/Product/LicensedProductType.php +msgid "Current Version" +msgstr "Aktuelle Version" + +#: src/Product/LicensedProductType.php +msgid "Current software version (e.g., 1.0.0)" +msgstr "Aktuelle Software-Version (z.B. 1.0.0)" + +#: src/License/LicenseManager.php +msgid "License key not found." +msgstr "Lizenzschlüssel nicht gefunden." + +#: src/License/LicenseManager.php +msgid "This license has been revoked." +msgstr "Diese Lizenz wurde widerrufen." + +#: src/License/LicenseManager.php +msgid "This license has expired." +msgstr "Diese Lizenz ist abgelaufen." + +#: src/License/LicenseManager.php +msgid "This license is inactive." +msgstr "Diese Lizenz ist inaktiv." + +#: src/License/LicenseManager.php +msgid "This license is not valid for this domain." +msgstr "Diese Lizenz ist für diese Domain nicht gültig." + +#: src/Api/RestApiController.php +msgid "This license is not valid." +msgstr "Diese Lizenz ist ungültig." + +#: src/Api/RestApiController.php +msgid "License is already activated for this domain." +msgstr "Die Lizenz ist bereits für diese Domain aktiviert." + +#: src/Api/RestApiController.php +msgid "Maximum number of activations reached." +msgstr "Maximale Anzahl der Aktivierungen erreicht." + +#: src/Api/RestApiController.php +msgid "Failed to activate license." +msgstr "Lizenz konnte nicht aktiviert werden." + +#: src/Api/RestApiController.php +msgid "License activated successfully." +msgstr "Lizenz erfolgreich aktiviert." + +#: src/Api/RestApiController.php +msgid "License is not activated for this domain." +msgstr "Die Lizenz ist für diese Domain nicht aktiviert." + +#: src/Api/RestApiController.php +msgid "Failed to deactivate license." +msgstr "Lizenz konnte nicht deaktiviert werden." + +#: src/Api/RestApiController.php +msgid "License deactivated successfully." +msgstr "Lizenz erfolgreich deaktiviert." + +#: src/Checkout/CheckoutController.php +msgid "License Domain" +msgstr "Lizenz-Domain" + +#: src/Checkout/CheckoutController.php +msgid "Domain for License Activation" +msgstr "Domain für Lizenz-Aktivierung" + +#: src/Checkout/CheckoutController.php +msgid "required" +msgstr "erforderlich" + +#: src/Checkout/CheckoutController.php +msgid "example.com" +msgstr "beispiel.ch" + +#: src/Checkout/CheckoutController.php +msgid "Enter the domain where you will use this license (without http:// or www)." +msgstr "Geben Sie die Domain ein, auf der Sie diese Lizenz verwenden möchten (ohne http:// oder www)." + +#: src/Checkout/CheckoutController.php +msgid "Please enter a domain for your license activation." +msgstr "Bitte geben Sie eine Domain für Ihre Lizenz-Aktivierung ein." + +#: src/Checkout/CheckoutController.php +msgid "Please enter a valid domain name." +msgstr "Bitte geben Sie einen gültigen Domain-Namen ein." + +#: src/Checkout/CheckoutController.php +msgid "License Domain:" +msgstr "Lizenz-Domain:" + +#: src/Frontend/AccountController.php +msgid "Please log in to view your licenses." +msgstr "Bitte melden Sie sich an, um Ihre Lizenzen zu sehen." + +#: src/Frontend/AccountController.php +msgid "Licenses" +msgstr "Lizenzen" + +#: src/Frontend/AccountController.php +msgid "Unknown Product" +msgstr "Unbekanntes Produkt" + +#: src/Frontend/AccountController.php +msgid "You have no licenses yet." +msgstr "Sie haben noch keine Lizenzen." + +#: src/Frontend/AccountController.php +msgid "License Key" +msgstr "Lizenzschlüssel" + +#: src/Frontend/AccountController.php +msgid "Product" +msgstr "Produkt" + +#: src/Frontend/AccountController.php +msgid "Domain" +msgstr "Domain" + +#: src/Frontend/AccountController.php +msgid "Status" +msgstr "Status" + +#: src/Frontend/AccountController.php +msgid "Expires" +msgstr "Läuft ab" + +#: src/Frontend/AccountController.php +msgid "Never" +msgstr "Nie" + +#: src/Admin/AdminController.php +msgid "Security check failed." +msgstr "Sicherheitsüberprüfung fehlgeschlagen." + +#: src/Admin/AdminController.php +msgid "License updated successfully." +msgstr "Lizenz erfolgreich aktualisiert." + +#: src/Admin/AdminController.php +msgid "License deleted successfully." +msgstr "Lizenz erfolgreich gelöscht." + +#: src/Admin/AdminController.php +msgid "License revoked successfully." +msgstr "Lizenz erfolgreich widerrufen." + +#: src/Admin/AdminController.php +msgid "Unknown" +msgstr "Unbekannt" + +#: src/Admin/AdminController.php +msgid "Guest" +msgstr "Gast" + +#: src/Admin/AdminController.php +msgid "Customer" +msgstr "Kunde" + +#: src/Admin/AdminController.php +msgid "Actions" +msgstr "Aktionen" + +#: src/Admin/AdminController.php +msgid "No licenses found." +msgstr "Keine Lizenzen gefunden." + +#: src/Admin/AdminController.php +msgid "Are you sure?" +msgstr "Sind Sie sicher?" + +#: src/Admin/AdminController.php +msgid "Revoke" +msgstr "Widerrufen" + +#: src/Admin/AdminController.php +msgid "Are you sure you want to delete this license?" +msgstr "Sind Sie sicher, dass Sie diese Lizenz löschen möchten?" + +#: src/Admin/AdminController.php +msgid "Delete" +msgstr "Löschen" + +#: src/Admin/AdminController.php +msgid "License" +msgstr "Lizenz" + +#: src/Admin/AdminController.php +msgid "No domain specified" +msgstr "Keine Domain angegeben" + +#: src/Admin/AdminController.php +msgid "Total licenses:" +msgstr "Lizenzen insgesamt:" + +#: src/Admin/AdminController.php +msgid "of" +msgstr "von" diff --git a/languages/wc-licensed-product.pot b/languages/wc-licensed-product.pot new file mode 100644 index 0000000..9839307 --- /dev/null +++ b/languages/wc-licensed-product.pot @@ -0,0 +1,253 @@ +# Copyright (C) 2024 Marco Graetsch +# This file is distributed under the GPL-2.0-or-later. +msgid "" +msgstr "" +"Project-Id-Version: WC Licensed Product 0.0.1\n" +"Report-Msgid-Bugs-To: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/issues\n" +"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" + +#: wc-licensed-product.php +msgid "%s requires WooCommerce to be installed and active." +msgstr "" + +#: wc-licensed-product.php +msgid "WC Licensed Product requires WooCommerce to be installed and active." +msgstr "" + +#: src/Product/LicensedProductType.php +msgid "Licensed Product" +msgstr "" + +#: src/Product/LicensedProductType.php +msgid "License Settings" +msgstr "" + +#: src/Product/LicensedProductType.php +msgid "Max Activations" +msgstr "" + +#: src/Product/LicensedProductType.php +msgid "Maximum number of domain activations per license. Default: 1" +msgstr "" + +#: src/Product/LicensedProductType.php +msgid "License Validity (Days)" +msgstr "" + +#: src/Product/LicensedProductType.php +msgid "Number of days the license is valid. Leave empty for lifetime license." +msgstr "" + +#: src/Product/LicensedProductType.php +msgid "Bind to Major Version" +msgstr "" + +#: src/Product/LicensedProductType.php +msgid "If enabled, licenses are bound to the major version at purchase time." +msgstr "" + +#: src/Product/LicensedProductType.php +msgid "Current Version" +msgstr "" + +#: src/Product/LicensedProductType.php +msgid "Current software version (e.g., 1.0.0)" +msgstr "" + +#: src/License/LicenseManager.php +msgid "License key not found." +msgstr "" + +#: src/License/LicenseManager.php +msgid "This license has been revoked." +msgstr "" + +#: src/License/LicenseManager.php +msgid "This license has expired." +msgstr "" + +#: src/License/LicenseManager.php +msgid "This license is inactive." +msgstr "" + +#: src/License/LicenseManager.php +msgid "This license is not valid for this domain." +msgstr "" + +#: src/Api/RestApiController.php +msgid "This license is not valid." +msgstr "" + +#: src/Api/RestApiController.php +msgid "License is already activated for this domain." +msgstr "" + +#: src/Api/RestApiController.php +msgid "Maximum number of activations reached." +msgstr "" + +#: src/Api/RestApiController.php +msgid "Failed to activate license." +msgstr "" + +#: src/Api/RestApiController.php +msgid "License activated successfully." +msgstr "" + +#: src/Api/RestApiController.php +msgid "License is not activated for this domain." +msgstr "" + +#: src/Api/RestApiController.php +msgid "Failed to deactivate license." +msgstr "" + +#: src/Api/RestApiController.php +msgid "License deactivated successfully." +msgstr "" + +#: src/Checkout/CheckoutController.php +msgid "License Domain" +msgstr "" + +#: src/Checkout/CheckoutController.php +msgid "Domain for License Activation" +msgstr "" + +#: src/Checkout/CheckoutController.php +msgid "required" +msgstr "" + +#: src/Checkout/CheckoutController.php +msgid "example.com" +msgstr "" + +#: src/Checkout/CheckoutController.php +msgid "Enter the domain where you will use this license (without http:// or www)." +msgstr "" + +#: src/Checkout/CheckoutController.php +msgid "Please enter a domain for your license activation." +msgstr "" + +#: src/Checkout/CheckoutController.php +msgid "Please enter a valid domain name." +msgstr "" + +#: src/Checkout/CheckoutController.php +msgid "License Domain:" +msgstr "" + +#: src/Frontend/AccountController.php +msgid "Please log in to view your licenses." +msgstr "" + +#: src/Frontend/AccountController.php +msgid "Licenses" +msgstr "" + +#: src/Frontend/AccountController.php +msgid "Unknown Product" +msgstr "" + +#: src/Frontend/AccountController.php +msgid "You have no licenses yet." +msgstr "" + +#: src/Frontend/AccountController.php +msgid "License Key" +msgstr "" + +#: src/Frontend/AccountController.php +msgid "Product" +msgstr "" + +#: src/Frontend/AccountController.php +msgid "Domain" +msgstr "" + +#: src/Frontend/AccountController.php +msgid "Status" +msgstr "" + +#: src/Frontend/AccountController.php +msgid "Expires" +msgstr "" + +#: src/Frontend/AccountController.php +msgid "Never" +msgstr "" + +#: src/Admin/AdminController.php +msgid "Security check failed." +msgstr "" + +#: src/Admin/AdminController.php +msgid "License updated successfully." +msgstr "" + +#: src/Admin/AdminController.php +msgid "License deleted successfully." +msgstr "" + +#: src/Admin/AdminController.php +msgid "License revoked successfully." +msgstr "" + +#: src/Admin/AdminController.php +msgid "Unknown" +msgstr "" + +#: src/Admin/AdminController.php +msgid "Guest" +msgstr "" + +#: src/Admin/AdminController.php +msgid "Customer" +msgstr "" + +#: src/Admin/AdminController.php +msgid "Actions" +msgstr "" + +#: src/Admin/AdminController.php +msgid "No licenses found." +msgstr "" + +#: src/Admin/AdminController.php +msgid "Are you sure?" +msgstr "" + +#: src/Admin/AdminController.php +msgid "Revoke" +msgstr "" + +#: src/Admin/AdminController.php +msgid "Are you sure you want to delete this license?" +msgstr "" + +#: src/Admin/AdminController.php +msgid "Delete" +msgstr "" + +#: src/Admin/AdminController.php +msgid "License" +msgstr "" + +#: src/Admin/AdminController.php +msgid "No domain specified" +msgstr "" + +#: src/Admin/AdminController.php +msgid "Total licenses:" +msgstr "" + +#: src/Admin/AdminController.php +msgid "of" +msgstr "" diff --git a/src/Admin/AdminController.php b/src/Admin/AdminController.php new file mode 100644 index 0000000..bb835b8 --- /dev/null +++ b/src/Admin/AdminController.php @@ -0,0 +1,412 @@ +twig = $twig; + $this->licenseManager = $licenseManager; + $this->registerHooks(); + } + + /** + * Register WordPress hooks + */ + private function registerHooks(): void + { + // Add admin menu + add_action('admin_menu', [$this, 'addAdminMenu']); + + // Enqueue admin styles + add_action('admin_enqueue_scripts', [$this, 'enqueueStyles']); + + // Handle admin actions + add_action('admin_init', [$this, 'handleAdminActions']); + + // Add licenses column to orders list + add_filter('manage_edit-shop_order_columns', [$this, 'addOrdersLicenseColumn']); + add_action('manage_shop_order_posts_custom_column', [$this, 'displayOrdersLicenseColumn'], 10, 2); + + // HPOS compatibility + add_filter('woocommerce_shop_order_list_table_columns', [$this, 'addOrdersLicenseColumn']); + add_action('woocommerce_shop_order_list_table_custom_column', [$this, 'displayOrdersLicenseColumnHpos'], 10, 2); + } + + /** + * Add admin menu pages + */ + public function addAdminMenu(): void + { + add_submenu_page( + 'woocommerce', + __('Licenses', 'wc-licensed-product'), + __('Licenses', 'wc-licensed-product'), + 'manage_woocommerce', + 'wc-licenses', + [$this, 'renderLicensesPage'] + ); + } + + /** + * Enqueue admin styles and scripts + */ + public function enqueueStyles(string $hook): void + { + if ($hook !== 'woocommerce_page_wc-licenses') { + return; + } + + wp_enqueue_style( + 'wc-licensed-product-admin', + WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/admin.css', + [], + WC_LICENSED_PRODUCT_VERSION + ); + } + + /** + * Handle admin actions (update, delete licenses) + */ + public function handleAdminActions(): void + { + if (!isset($_GET['page']) || $_GET['page'] !== 'wc-licenses') { + return; + } + + if (!current_user_can('manage_woocommerce')) { + return; + } + + // Handle status update + if (isset($_POST['action']) && $_POST['action'] === 'update_license_status') { + $this->handleStatusUpdate(); + } + + // Handle delete + if (isset($_GET['action']) && $_GET['action'] === 'delete' && isset($_GET['license_id'])) { + $this->handleDelete(); + } + + // Handle revoke + if (isset($_GET['action']) && $_GET['action'] === 'revoke' && isset($_GET['license_id'])) { + $this->handleRevoke(); + } + } + + /** + * Handle license status update + */ + private function handleStatusUpdate(): void + { + if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'update_license_status')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $licenseId = absint($_POST['license_id'] ?? 0); + $status = sanitize_text_field($_POST['status'] ?? ''); + + if ($licenseId && in_array($status, [License::STATUS_ACTIVE, License::STATUS_INACTIVE, License::STATUS_REVOKED], true)) { + $this->licenseManager->updateLicenseStatus($licenseId, $status); + + wp_redirect(admin_url('admin.php?page=wc-licenses&updated=1')); + exit; + } + } + + /** + * Handle license deletion + */ + private function handleDelete(): void + { + if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'delete_license')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $licenseId = absint($_GET['license_id'] ?? 0); + if ($licenseId) { + $this->licenseManager->deleteLicense($licenseId); + + wp_redirect(admin_url('admin.php?page=wc-licenses&deleted=1')); + exit; + } + } + + /** + * Handle license revocation + */ + private function handleRevoke(): void + { + if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'revoke_license')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $licenseId = absint($_GET['license_id'] ?? 0); + if ($licenseId) { + $this->licenseManager->updateLicenseStatus($licenseId, License::STATUS_REVOKED); + + wp_redirect(admin_url('admin.php?page=wc-licenses&revoked=1')); + exit; + } + } + + /** + * Render licenses admin page + */ + public function renderLicensesPage(): void + { + $page = isset($_GET['paged']) ? absint($_GET['paged']) : 1; + $perPage = 20; + + $licenses = $this->licenseManager->getAllLicenses($page, $perPage); + $totalLicenses = $this->licenseManager->getLicenseCount(); + $totalPages = ceil($totalLicenses / $perPage); + + // Enrich licenses with related data + $enrichedLicenses = []; + foreach ($licenses as $license) { + $product = wc_get_product($license->getProductId()); + $order = wc_get_order($license->getOrderId()); + $customer = get_userdata($license->getCustomerId()); + + $enrichedLicenses[] = [ + 'license' => $license, + 'product_name' => $product ? $product->get_name() : __('Unknown', 'wc-licensed-product'), + 'product_edit_url' => $product ? get_edit_post_link($product->get_id()) : '', + 'order_number' => $order ? $order->get_order_number() : '', + 'order_edit_url' => $order ? $order->get_edit_order_url() : '', + 'customer_name' => $customer ? $customer->display_name : __('Guest', 'wc-licensed-product'), + 'customer_email' => $customer ? $customer->user_email : '', + ]; + } + + try { + echo $this->twig->render('admin/licenses.html.twig', [ + 'licenses' => $enrichedLicenses, + 'current_page' => $page, + 'total_pages' => $totalPages, + 'total_licenses' => $totalLicenses, + 'admin_url' => admin_url('admin.php?page=wc-licenses'), + 'notices' => $this->getNotices(), + ]); + } catch (\Exception $e) { + // Fallback to PHP template + $this->renderLicensesPageFallback($enrichedLicenses, $page, $totalPages, $totalLicenses); + } + } + + /** + * Get admin notices + */ + private function getNotices(): array + { + $notices = []; + + if (isset($_GET['updated'])) { + $notices[] = ['type' => 'success', 'message' => __('License updated successfully.', 'wc-licensed-product')]; + } + if (isset($_GET['deleted'])) { + $notices[] = ['type' => 'success', 'message' => __('License deleted successfully.', 'wc-licensed-product')]; + } + if (isset($_GET['revoked'])) { + $notices[] = ['type' => 'success', 'message' => __('License revoked successfully.', 'wc-licensed-product')]; + } + + return $notices; + } + + /** + * Fallback render for licenses page + */ + private function renderLicensesPageFallback(array $enrichedLicenses, int $page, int $totalPages, int $totalLicenses): void + { + ?> +
+

+ + getNotices() as $notice): ?> +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
getLicenseKey()); ?> + + + + + + + + + + +
+ +
getDomain()); ?> + + getStatus())); ?> + + + getExpiresAt(); + echo $expiresAt + ? esc_html($expiresAt->format(get_option('date_format'))) + : esc_html__('Never', 'wc-licensed-product'); + ?> + + getStatus() !== License::STATUS_REVOKED): ?> + + + + + + + +
+ + 1): ?> +
+
+ admin_url('admin.php?page=wc-licenses&paged=%#%'), + 'format' => '', + 'current' => $page, + 'total' => $totalPages, + ]); + ?> +
+
+ +
+ $value) { + $newColumns[$key] = $value; + if ($key === 'order_status') { + $newColumns['license'] = __('License', 'wc-licensed-product'); + } + } + return $newColumns; + } + + /** + * Display license column content + */ + public function displayOrdersLicenseColumn(string $column, int $postId): void + { + if ($column !== 'license') { + return; + } + + $order = wc_get_order($postId); + $this->outputLicenseColumnContent($order); + } + + /** + * Display license column content (HPOS) + */ + public function displayOrdersLicenseColumnHpos(string $column, \WC_Order $order): void + { + if ($column !== 'license') { + return; + } + + $this->outputLicenseColumnContent($order); + } + + /** + * Output license column content + */ + private function outputLicenseColumnContent(?\WC_Order $order): void + { + if (!$order) { + echo '—'; + return; + } + + $hasLicensedProduct = false; + foreach ($order->get_items() as $item) { + $product = $item->get_product(); + if ($product && $product->is_type('licensed')) { + $hasLicensedProduct = true; + break; + } + } + + if (!$hasLicensedProduct) { + echo '—'; + return; + } + + $domain = $order->get_meta('_licensed_product_domain'); + if ($domain) { + echo ' ' . esc_html($domain); + } else { + echo ''; + } + } +} diff --git a/src/Api/RestApiController.php b/src/Api/RestApiController.php new file mode 100644 index 0000000..66ab47e --- /dev/null +++ b/src/Api/RestApiController.php @@ -0,0 +1,271 @@ +licenseManager = $licenseManager; + $this->registerHooks(); + } + + /** + * Register WordPress hooks + */ + private function registerHooks(): void + { + add_action('rest_api_init', [$this, 'registerRoutes']); + } + + /** + * Register REST API routes + */ + public function registerRoutes(): void + { + // Validate license endpoint (public) + register_rest_route(self::NAMESPACE, '/validate', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'validateLicense'], + 'permission_callback' => '__return_true', + 'args' => [ + 'license_key' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ($value): bool { + return !empty($value) && strlen($value) <= 64; + }, + ], + 'domain' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ($value): bool { + return !empty($value) && strlen($value) <= 255; + }, + ], + ], + ]); + + // Check license status endpoint (public) + register_rest_route(self::NAMESPACE, '/status', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'checkStatus'], + 'permission_callback' => '__return_true', + 'args' => [ + 'license_key' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ]); + + // Activate license on domain endpoint (public) + register_rest_route(self::NAMESPACE, '/activate', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [$this, 'activateLicense'], + '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', + ], + ], + ]); + + // 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', + ], + ], + ]); + } + + /** + * Validate license endpoint + */ + public function validateLicense(WP_REST_Request $request): WP_REST_Response + { + $licenseKey = $request->get_param('license_key'); + $domain = $request->get_param('domain'); + + $result = $this->licenseManager->validateLicense($licenseKey, $domain); + + $statusCode = $result['valid'] ? 200 : 403; + + return new WP_REST_Response($result, $statusCode); + } + + /** + * Check license status endpoint + */ + public function checkStatus(WP_REST_Request $request): WP_REST_Response + { + $licenseKey = $request->get_param('license_key'); + $license = $this->licenseManager->getLicenseByKey($licenseKey); + + if (!$license) { + return new WP_REST_Response([ + 'valid' => false, + 'error' => 'license_not_found', + 'message' => __('License key not found.', 'wc-licensed-product'), + ], 404); + } + + return new WP_REST_Response([ + 'valid' => $license->isValid(), + 'status' => $license->getStatus(), + 'domain' => $license->getDomain(), + 'expires_at' => $license->getExpiresAt()?->format('Y-m-d'), + 'activations_count' => $license->getActivationsCount(), + 'max_activations' => $license->getMaxActivations(), + ]); + } + + /** + * Activate license on domain endpoint + */ + public function activateLicense(WP_REST_Request $request): WP_REST_Response + { + $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); + } + + if (!$license->isValid()) { + return new WP_REST_Response([ + 'success' => false, + 'error' => 'license_invalid', + 'message' => __('This license is not valid.', 'wc-licensed-product'), + ], 403); + } + + $normalizedDomain = $this->licenseManager->normalizeDomain($domain); + + // Check if already activated on this domain + if ($license->getDomain() === $normalizedDomain) { + return new WP_REST_Response([ + 'success' => true, + 'message' => __('License is already activated for this domain.', 'wc-licensed-product'), + ]); + } + + // Check if can activate on another domain + if (!$license->canActivate()) { + return new WP_REST_Response([ + 'success' => false, + 'error' => 'max_activations_reached', + 'message' => __('Maximum number of activations reached.', 'wc-licensed-product'), + ], 403); + } + + // Update domain (in this simple implementation, we replace the domain) + $success = $this->licenseManager->updateLicenseDomain($license->getId(), $domain); + + if (!$success) { + return new WP_REST_Response([ + 'success' => false, + 'error' => 'activation_failed', + 'message' => __('Failed to activate license.', 'wc-licensed-product'), + ], 500); + } + + return new WP_REST_Response([ + 'success' => true, + 'message' => __('License activated successfully.', 'wc-licensed-product'), + ]); + } + + /** + * Deactivate license endpoint + */ + public function deactivateLicense(WP_REST_Request $request): WP_REST_Response + { + $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'), + ]); + } +} diff --git a/src/Checkout/CheckoutController.php b/src/Checkout/CheckoutController.php new file mode 100644 index 0000000..c997957 --- /dev/null +++ b/src/Checkout/CheckoutController.php @@ -0,0 +1,207 @@ +licenseManager = $licenseManager; + $this->registerHooks(); + } + + /** + * Register WordPress hooks + */ + private function registerHooks(): void + { + // Add domain field to checkout + add_action('woocommerce_after_order_notes', [$this, 'addDomainField']); + + // Validate domain field + add_action('woocommerce_checkout_process', [$this, 'validateDomainField']); + + // Save domain field to order meta + add_action('woocommerce_checkout_update_order_meta', [$this, 'saveDomainField']); + + // Display domain in order details (admin) + add_action('woocommerce_admin_order_data_after_billing_address', [$this, 'displayDomainInAdmin']); + + // Display domain in order email + add_action('woocommerce_email_after_order_table', [$this, 'displayDomainInEmail'], 10, 3); + } + + /** + * Check if cart contains licensed products + */ + private function cartHasLicensedProducts(): bool + { + if (!WC()->cart) { + return false; + } + + foreach (WC()->cart->get_cart() as $cartItem) { + $product = $cartItem['data']; + if ($product && $product->is_type('licensed')) { + return true; + } + } + + return false; + } + + /** + * Add domain field to checkout form + */ + public function addDomainField(): void + { + if (!$this->cartHasLicensedProducts()) { + return; + } + + ?> +
+

+

+ + + + + +

+
+ cartHasLicensedProducts()) { + return; + } + + $domain = isset($_POST['licensed_product_domain']) + ? sanitize_text_field($_POST['licensed_product_domain']) + : ''; + + if (empty($domain)) { + wc_add_notice( + __('Please enter a domain for your license activation.', 'wc-licensed-product'), + 'error' + ); + return; + } + + // Validate domain format + $normalizedDomain = $this->licenseManager->normalizeDomain($domain); + if (!$this->isValidDomain($normalizedDomain)) { + wc_add_notice( + __('Please enter a valid domain name.', 'wc-licensed-product'), + 'error' + ); + } + } + + /** + * Save domain field to order meta + */ + public function saveDomainField(int $orderId): void + { + if (!$this->cartHasLicensedProducts()) { + return; + } + + if (isset($_POST['licensed_product_domain']) && !empty($_POST['licensed_product_domain'])) { + $domain = sanitize_text_field($_POST['licensed_product_domain']); + $normalizedDomain = $this->licenseManager->normalizeDomain($domain); + + $order = wc_get_order($orderId); + if ($order) { + $order->update_meta_data('_licensed_product_domain', $normalizedDomain); + $order->save(); + } + } + } + + /** + * Display domain in admin order view + */ + public function displayDomainInAdmin(\WC_Order $order): void + { + $domain = $order->get_meta('_licensed_product_domain'); + if (!$domain) { + return; + } + + ?> +

+ + +

+ get_meta('_licensed_product_domain'); + if (!$domain) { + return; + } + + if ($plainText) { + echo "\n" . esc_html__('License Domain:', 'wc-licensed-product') . ' ' . esc_html($domain) . "\n"; + } else { + ?> +

+ + +

+ 255) { + return false; + } + + // Check for valid domain pattern + $pattern = '/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'; + + return (bool) preg_match($pattern, $domain); + } +} diff --git a/src/Frontend/AccountController.php b/src/Frontend/AccountController.php new file mode 100644 index 0000000..add2fd3 --- /dev/null +++ b/src/Frontend/AccountController.php @@ -0,0 +1,187 @@ +twig = $twig; + $this->licenseManager = $licenseManager; + $this->registerHooks(); + } + + /** + * Register WordPress hooks + */ + private function registerHooks(): void + { + // Add licenses endpoint + add_action('init', [$this, 'addLicensesEndpoint']); + + // Add licenses menu item + add_filter('woocommerce_account_menu_items', [$this, 'addLicensesMenuItem']); + + // Add licenses endpoint content + add_action('woocommerce_account_licenses_endpoint', [$this, 'displayLicensesContent']); + + // Enqueue frontend styles + add_action('wp_enqueue_scripts', [$this, 'enqueueStyles']); + } + + /** + * Add licenses endpoint for My Account + */ + public function addLicensesEndpoint(): void + { + add_rewrite_endpoint('licenses', EP_ROOT | EP_PAGES); + } + + /** + * Add licenses menu item to My Account navigation + */ + public function addLicensesMenuItem(array $items): array + { + // Insert licenses after orders + $newItems = []; + foreach ($items as $key => $value) { + $newItems[$key] = $value; + if ($key === 'orders') { + $newItems['licenses'] = __('Licenses', 'wc-licensed-product'); + } + } + + return $newItems; + } + + /** + * Display licenses content in My Account + */ + public function displayLicensesContent(): void + { + $customerId = get_current_user_id(); + if (!$customerId) { + echo '

' . esc_html__('Please log in to view your licenses.', 'wc-licensed-product') . '

'; + return; + } + + $licenses = $this->licenseManager->getLicensesByCustomer($customerId); + + // Enrich licenses with product data + $enrichedLicenses = []; + foreach ($licenses as $license) { + $product = wc_get_product($license->getProductId()); + $order = wc_get_order($license->getOrderId()); + + $enrichedLicenses[] = [ + 'license' => $license, + 'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'), + 'product_url' => $product ? $product->get_permalink() : '', + 'order_number' => $order ? $order->get_order_number() : '', + 'order_url' => $order ? $order->get_view_order_url() : '', + ]; + } + + try { + echo $this->twig->render('frontend/licenses.html.twig', [ + 'licenses' => $enrichedLicenses, + 'has_licenses' => !empty($enrichedLicenses), + ]); + } catch (\Exception $e) { + // Fallback to PHP template if Twig fails + $this->displayLicensesFallback($enrichedLicenses); + } + } + + /** + * Fallback display method if Twig is unavailable + */ + private function displayLicensesFallback(array $enrichedLicenses): void + { + if (empty($enrichedLicenses)) { + echo '

' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '

'; + return; + } + + ?> + + + + + + + + + + + + + + + + + + + + + +
+ getLicenseKey()); ?> + + + + + + + + + + getDomain()); ?> + + + getStatus())); ?> + + + getExpiresAt(); + echo $expiresAt + ? esc_html($expiresAt->format(get_option('date_format'))) + : esc_html__('Never', 'wc-licensed-product'); + ?> +
+ get_charset_collate(); + + $licensesTable = $wpdb->prefix . self::TABLE_LICENSES; + $versionsTable = $wpdb->prefix . self::TABLE_PRODUCT_VERSIONS; + + $sqlLicenses = "CREATE TABLE {$licensesTable} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + license_key VARCHAR(64) NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + customer_id BIGINT UNSIGNED NOT NULL, + domain VARCHAR(255) NOT NULL, + version_id BIGINT UNSIGNED DEFAULT NULL, + status ENUM('active', 'inactive', 'expired', 'revoked') NOT NULL DEFAULT 'active', + activations_count INT UNSIGNED NOT NULL DEFAULT 0, + max_activations INT UNSIGNED NOT NULL DEFAULT 1, + expires_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY license_key (license_key), + KEY order_id (order_id), + KEY product_id (product_id), + KEY customer_id (customer_id), + KEY domain (domain), + KEY status (status) + ) {$charsetCollate};"; + + $sqlVersions = "CREATE TABLE {$versionsTable} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + version VARCHAR(32) NOT NULL, + major_version INT UNSIGNED NOT NULL, + minor_version INT UNSIGNED NOT NULL, + patch_version INT UNSIGNED NOT NULL, + release_notes TEXT DEFAULT NULL, + download_url VARCHAR(512) DEFAULT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY product_version (product_id, version), + KEY product_id (product_id), + KEY major_version (major_version), + KEY is_active (is_active) + ) {$charsetCollate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta($sqlLicenses); + dbDelta($sqlVersions); + } + + /** + * Create Twig cache directory + */ + private static function createCacheDir(): void + { + $cacheDir = WP_CONTENT_DIR . '/cache/wc-licensed-product/twig'; + if (!file_exists($cacheDir)) { + wp_mkdir_p($cacheDir); + } + } + + /** + * Get licenses table name + */ + public static function getLicensesTable(): string + { + global $wpdb; + return $wpdb->prefix . self::TABLE_LICENSES; + } + + /** + * Get product versions table name + */ + public static function getVersionsTable(): string + { + global $wpdb; + return $wpdb->prefix . self::TABLE_PRODUCT_VERSIONS; + } +} diff --git a/src/License/License.php b/src/License/License.php new file mode 100644 index 0000000..aa71e09 --- /dev/null +++ b/src/License/License.php @@ -0,0 +1,178 @@ +id = (int) $data['id']; + $license->licenseKey = $data['license_key']; + $license->orderId = (int) $data['order_id']; + $license->productId = (int) $data['product_id']; + $license->customerId = (int) $data['customer_id']; + $license->domain = $data['domain']; + $license->versionId = $data['version_id'] ? (int) $data['version_id'] : null; + $license->status = $data['status']; + $license->activationsCount = (int) $data['activations_count']; + $license->maxActivations = (int) $data['max_activations']; + $license->expiresAt = $data['expires_at'] ? new \DateTimeImmutable($data['expires_at']) : null; + $license->createdAt = new \DateTimeImmutable($data['created_at']); + $license->updatedAt = new \DateTimeImmutable($data['updated_at']); + + return $license; + } + + public function getId(): int + { + return $this->id; + } + + public function getLicenseKey(): string + { + return $this->licenseKey; + } + + public function getOrderId(): int + { + return $this->orderId; + } + + public function getProductId(): int + { + return $this->productId; + } + + public function getCustomerId(): int + { + return $this->customerId; + } + + public function getDomain(): string + { + return $this->domain; + } + + public function getVersionId(): ?int + { + return $this->versionId; + } + + public function getStatus(): string + { + return $this->status; + } + + public function getActivationsCount(): int + { + return $this->activationsCount; + } + + public function getMaxActivations(): int + { + return $this->maxActivations; + } + + public function getExpiresAt(): ?\DateTimeInterface + { + return $this->expiresAt; + } + + public function getCreatedAt(): \DateTimeInterface + { + return $this->createdAt; + } + + public function getUpdatedAt(): \DateTimeInterface + { + return $this->updatedAt; + } + + /** + * Check if license is currently valid + */ + public function isValid(): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + if ($this->expiresAt !== null && $this->expiresAt < new \DateTimeImmutable()) { + return false; + } + + return true; + } + + /** + * Check if license has expired + */ + public function isExpired(): bool + { + return $this->expiresAt !== null && $this->expiresAt < new \DateTimeImmutable(); + } + + /** + * Check if license can be activated on another domain + */ + public function canActivate(): bool + { + return $this->isValid() && $this->activationsCount < $this->maxActivations; + } + + /** + * Convert to array for JSON/API responses + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'license_key' => $this->licenseKey, + 'order_id' => $this->orderId, + 'product_id' => $this->productId, + 'customer_id' => $this->customerId, + 'domain' => $this->domain, + 'version_id' => $this->versionId, + 'status' => $this->status, + 'activations_count' => $this->activationsCount, + 'max_activations' => $this->maxActivations, + 'expires_at' => $this->expiresAt?->format('Y-m-d H:i:s'), + 'created_at' => $this->createdAt->format('Y-m-d H:i:s'), + 'updated_at' => $this->updatedAt->format('Y-m-d H:i:s'), + 'is_valid' => $this->isValid(), + ]; + } +} diff --git a/src/License/LicenseManager.php b/src/License/LicenseManager.php new file mode 100644 index 0000000..f49fffe --- /dev/null +++ b/src/License/LicenseManager.php @@ -0,0 +1,363 @@ + 0) { + $key .= '-'; + } + for ($j = 0; $j < 4; $j++) { + $key .= $chars[random_int(0, strlen($chars) - 1)]; + } + } + + return $key; + } + + /** + * Generate a license for a completed order + */ + public function generateLicense( + int $orderId, + int $productId, + int $customerId, + string $domain + ): ?License { + global $wpdb; + + // Check if license already exists for this order and product + $existing = $this->getLicenseByOrderAndProduct($orderId, $productId); + if ($existing) { + return $existing; + } + + $product = wc_get_product($productId); + if (!$product instanceof LicensedProduct) { + return null; + } + + // Generate unique license key + $licenseKey = $this->generateLicenseKey(); + while ($this->getLicenseByKey($licenseKey)) { + $licenseKey = $this->generateLicenseKey(); + } + + // Calculate expiration date + $expiresAt = null; + $validityDays = $product->get_validity_days(); + if ($validityDays !== null && $validityDays > 0) { + $expiresAt = (new \DateTimeImmutable())->modify("+{$validityDays} days")->format('Y-m-d H:i:s'); + } + + // Determine version ID if bound to version + $versionId = null; + if ($product->is_bound_to_version()) { + $versionId = $this->getCurrentVersionId($productId); + } + + $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' => $versionId, + 'status' => License::STATUS_ACTIVE, + 'activations_count' => 1, + 'max_activations' => $product->get_max_activations(), + 'expires_at' => $expiresAt, + ], + ['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s'] + ); + + if ($result === false) { + return null; + } + + return $this->getLicenseById((int) $wpdb->insert_id); + } + + /** + * Get license by ID + */ + public function getLicenseById(int $id): ?License + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $row = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$tableName} WHERE id = %d", $id), + ARRAY_A + ); + + return $row ? License::fromArray($row) : null; + } + + /** + * Get license by license key + */ + public function getLicenseByKey(string $licenseKey): ?License + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $row = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$tableName} WHERE license_key = %s", $licenseKey), + ARRAY_A + ); + + return $row ? License::fromArray($row) : null; + } + + /** + * Get license by order and product + */ + public function getLicenseByOrderAndProduct(int $orderId, int $productId): ?License + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d", + $orderId, + $productId + ), + ARRAY_A + ); + + return $row ? License::fromArray($row) : null; + } + + /** + * Get all licenses for a customer + */ + public function getLicensesByCustomer(int $customerId): array + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$tableName} WHERE customer_id = %d ORDER BY created_at DESC", + $customerId + ), + ARRAY_A + ); + + return array_map(fn(array $row) => License::fromArray($row), $rows ?: []); + } + + /** + * Get all licenses (for admin) + */ + public function getAllLicenses(int $page = 1, int $perPage = 20): array + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $offset = ($page - 1) * $perPage; + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$tableName} ORDER BY created_at DESC LIMIT %d OFFSET %d", + $perPage, + $offset + ), + ARRAY_A + ); + + return array_map(fn(array $row) => License::fromArray($row), $rows ?: []); + } + + /** + * Get total license count + */ + public function getLicenseCount(): int + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tableName}"); + } + + /** + * Validate a license key for a domain + */ + public function validateLicense(string $licenseKey, string $domain): array + { + $license = $this->getLicenseByKey($licenseKey); + + if (!$license) { + return [ + 'valid' => false, + 'error' => 'license_not_found', + 'message' => __('License key not found.', 'wc-licensed-product'), + ]; + } + + // Check license status + if ($license->getStatus() === License::STATUS_REVOKED) { + return [ + 'valid' => false, + 'error' => 'license_revoked', + 'message' => __('This license has been revoked.', 'wc-licensed-product'), + ]; + } + + // Check expiration + if ($license->isExpired()) { + $this->updateLicenseStatus($license->getId(), License::STATUS_EXPIRED); + return [ + 'valid' => false, + 'error' => 'license_expired', + 'message' => __('This license has expired.', 'wc-licensed-product'), + ]; + } + + if ($license->getStatus() === License::STATUS_INACTIVE) { + return [ + 'valid' => false, + 'error' => 'license_inactive', + 'message' => __('This license is inactive.', 'wc-licensed-product'), + ]; + } + + // Check domain + $normalizedDomain = $this->normalizeDomain($domain); + if ($license->getDomain() !== $normalizedDomain) { + return [ + 'valid' => false, + 'error' => 'domain_mismatch', + 'message' => __('This license is not valid for this domain.', 'wc-licensed-product'), + ]; + } + + return [ + 'valid' => true, + 'license' => [ + 'product_id' => $license->getProductId(), + 'expires_at' => $license->getExpiresAt()?->format('Y-m-d'), + 'version_id' => $license->getVersionId(), + ], + ]; + } + + /** + * Update license status + */ + public function updateLicenseStatus(int $licenseId, string $status): bool + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $result = $wpdb->update( + $tableName, + ['status' => $status], + ['id' => $licenseId], + ['%s'], + ['%d'] + ); + + return $result !== false; + } + + /** + * Update license domain + */ + public function updateLicenseDomain(int $licenseId, string $domain): bool + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $result = $wpdb->update( + $tableName, + ['domain' => $this->normalizeDomain($domain)], + ['id' => $licenseId], + ['%s'], + ['%d'] + ); + + return $result !== false; + } + + /** + * Delete a license + */ + public function deleteLicense(int $licenseId): bool + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $result = $wpdb->delete( + $tableName, + ['id' => $licenseId], + ['%d'] + ); + + return $result !== false; + } + + /** + * Normalize domain name + */ + public function normalizeDomain(string $domain): string + { + // Remove protocol + $domain = preg_replace('#^https?://#', '', $domain); + + // Remove trailing slash and path + $domain = preg_replace('#/.*$#', '', $domain); + + // Remove www prefix + $domain = preg_replace('#^www\.#', '', $domain); + + // Lowercase + return strtolower(trim($domain)); + } + + /** + * Get current version ID for a product + */ + private function getCurrentVersionId(int $productId): ?int + { + global $wpdb; + + $tableName = Installer::getVersionsTable(); + $versionId = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$tableName} WHERE product_id = %d AND is_active = 1 ORDER BY released_at DESC LIMIT 1", + $productId + ) + ); + + return $versionId ? (int) $versionId : null; + } +} diff --git a/src/Plugin.php b/src/Plugin.php new file mode 100644 index 0000000..dfa51d9 --- /dev/null +++ b/src/Plugin.php @@ -0,0 +1,163 @@ +initTwig(); + $this->initComponents(); + $this->registerHooks(); + } + + /** + * Initialize Twig environment + */ + private function initTwig(): void + { + $loader = new FilesystemLoader(WC_LICENSED_PRODUCT_PLUGIN_DIR . 'templates'); + $this->twig = new Environment($loader, [ + 'cache' => WP_CONTENT_DIR . '/cache/wc-licensed-product/twig', + 'auto_reload' => WP_DEBUG, + ]); + + // Add WordPress functions as Twig functions + $this->twig->addFunction(new \Twig\TwigFunction('__', function (string $text, string $domain = 'wc-licensed-product'): string { + return __($text, $domain); + })); + + $this->twig->addFunction(new \Twig\TwigFunction('esc_html', 'esc_html')); + $this->twig->addFunction(new \Twig\TwigFunction('esc_attr', 'esc_attr')); + $this->twig->addFunction(new \Twig\TwigFunction('esc_url', 'esc_url')); + $this->twig->addFunction(new \Twig\TwigFunction('wp_nonce_field', 'wp_nonce_field', ['is_safe' => ['html']])); + $this->twig->addFunction(new \Twig\TwigFunction('admin_url', 'admin_url')); + $this->twig->addFunction(new \Twig\TwigFunction('wc_get_endpoint_url', 'wc_get_endpoint_url')); + } + + /** + * Initialize plugin components + */ + private function initComponents(): void + { + $this->licenseManager = new LicenseManager(); + + // Initialize controllers + new LicensedProductType(); + new CheckoutController($this->licenseManager); + new AccountController($this->twig, $this->licenseManager); + new RestApiController($this->licenseManager); + + if (is_admin()) { + new AdminController($this->twig, $this->licenseManager); + } + } + + /** + * Register plugin hooks + */ + private function registerHooks(): void + { + // Generate license on order completion + add_action('woocommerce_order_status_completed', [$this, 'onOrderCompleted']); + } + + /** + * Handle order completion - generate licenses + */ + public function onOrderCompleted(int $orderId): void + { + $order = wc_get_order($orderId); + if (!$order) { + return; + } + + foreach ($order->get_items() as $item) { + $product = $item->get_product(); + if ($product && $product->is_type('licensed')) { + $domain = $order->get_meta('_licensed_product_domain'); + if ($domain) { + $this->licenseManager->generateLicense( + $orderId, + $product->get_id(), + $order->get_customer_id(), + $domain + ); + } + } + } + } + + /** + * Get Twig environment + */ + public function getTwig(): Environment + { + return $this->twig; + } + + /** + * Get license manager + */ + public function getLicenseManager(): LicenseManager + { + return $this->licenseManager; + } + + /** + * Render a Twig template + */ + public function render(string $template, array $context = []): string + { + return $this->twig->render($template, $context); + } +} diff --git a/src/Product/LicensedProduct.php b/src/Product/LicensedProduct.php new file mode 100644 index 0000000..cd5d2f0 --- /dev/null +++ b/src/Product/LicensedProduct.php @@ -0,0 +1,103 @@ +exists() && $this->get_price() !== ''; + } + + /** + * Get max activations for this product + */ + public function get_max_activations(): int + { + $value = $this->get_meta('_licensed_max_activations', true); + return $value ? (int) $value : 1; + } + + /** + * Get validity days + */ + public function get_validity_days(): ?int + { + $value = $this->get_meta('_licensed_validity_days', true); + return $value !== '' ? (int) $value : null; + } + + /** + * Check if license should be bound to major version + */ + public function is_bound_to_version(): bool + { + return $this->get_meta('_licensed_bind_to_version', true) === 'yes'; + } + + /** + * Get current software version + */ + public function get_current_version(): string + { + return $this->get_meta('_licensed_current_version', true) ?: ''; + } + + /** + * Get major version number from version string + */ + public function get_major_version(): int + { + $version = $this->get_current_version(); + if (empty($version)) { + return 1; + } + + $parts = explode('.', $version); + return (int) ($parts[0] ?? 1); + } +} diff --git a/src/Product/LicensedProductType.php b/src/Product/LicensedProductType.php new file mode 100644 index 0000000..26e6afb --- /dev/null +++ b/src/Product/LicensedProductType.php @@ -0,0 +1,200 @@ +registerHooks(); + } + + /** + * Register WordPress hooks + */ + private function registerHooks(): void + { + // Register product type + add_filter('product_type_selector', [$this, 'addProductType']); + add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 2); + + // Add product data tabs + add_filter('woocommerce_product_data_tabs', [$this, 'addProductDataTab']); + add_action('woocommerce_product_data_panels', [$this, 'addProductDataPanel']); + + // Save product meta + add_action('woocommerce_process_product_meta_licensed', [$this, 'saveProductMeta']); + + // Show price and add to cart for licensed products + add_action('woocommerce_licensed_add_to_cart', [$this, 'addToCartTemplate']); + + // Make product virtual by default + add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2); + } + + /** + * Add product type to selector + */ + public function addProductType(array $types): array + { + $types['licensed'] = __('Licensed Product', 'wc-licensed-product'); + return $types; + } + + /** + * Get product class for licensed type + */ + public function getProductClass(string $className, string $productType): string + { + if ($productType === 'licensed') { + return LicensedProduct::class; + } + return $className; + } + + /** + * Add product data tab for license settings + */ + public function addProductDataTab(array $tabs): array + { + $tabs['licensed_product'] = [ + 'label' => __('License Settings', 'wc-licensed-product'), + 'target' => 'licensed_product_data', + 'class' => ['show_if_licensed'], + 'priority' => 21, + ]; + return $tabs; + } + + /** + * Add product data panel content + */ + public function addProductDataPanel(): void + { + global $post; + ?> + + + is_type('licensed')) { + return true; + } + return $isVirtual; + } +} diff --git a/templates/admin/licenses.html.twig b/templates/admin/licenses.html.twig new file mode 100644 index 0000000..462cc7e --- /dev/null +++ b/templates/admin/licenses.html.twig @@ -0,0 +1,102 @@ +
+

{{ __('Licenses') }}

+ + {% for notice in notices %} +
+

{{ esc_html(notice.message) }}

+
+ {% endfor %} + +

+ {{ __('Total licenses:') }} {{ total_licenses }} +

+ + + + + + + + + + + + + + + {% if licenses is empty %} + + + + {% else %} + {% for item in licenses %} + + + + + + + + + + {% endfor %} + {% endif %} + +
{{ __('License Key') }}{{ __('Product') }}{{ __('Customer') }}{{ __('Domain') }}{{ __('Status') }}{{ __('Expires') }}{{ __('Actions') }}
{{ __('No licenses found.') }}
{{ item.license.licenseKey }} + {% if item.product_edit_url %} + {{ esc_html(item.product_name) }} + {% else %} + {{ esc_html(item.product_name) }} + {% endif %} + + {{ esc_html(item.customer_name) }} + {% if item.customer_email %} +
{{ esc_html(item.customer_email) }} + {% endif %} +
{{ esc_html(item.license.domain) }} + + {{ item.license.status|capitalize }} + + + {% if item.license.expiresAt %} + {{ item.license.expiresAt|date('Y-m-d') }} + {% else %} + {{ __('Never') }} + {% endif %} + + {% if item.license.status != 'revoked' %} + + {{ __('Revoke') }} + + {% endif %} + + {{ __('Delete') }} + +
+ + {% if total_pages > 1 %} +
+
+ + {% if current_page > 1 %} + + + + {% endif %} + + {{ current_page }} {{ __('of') }} {{ total_pages }} + + {% if current_page < total_pages %} + + + + {% endif %} + +
+
+ {% endif %} +
diff --git a/templates/frontend/licenses.html.twig b/templates/frontend/licenses.html.twig new file mode 100644 index 0000000..f2792b7 --- /dev/null +++ b/templates/frontend/licenses.html.twig @@ -0,0 +1,46 @@ +{% if not has_licenses %} +

{{ __('You have no licenses yet.') }}

+{% else %} + + + + + + + + + + + + {% for item in licenses %} + + + + + + + + {% endfor %} + +
{{ __('License Key') }}{{ __('Product') }}{{ __('Domain') }}{{ __('Status') }}{{ __('Expires') }}
+ {{ item.license.licenseKey }} + + {% if item.product_url %} + {{ esc_html(item.product_name) }} + {% else %} + {{ esc_html(item.product_name) }} + {% endif %} + + {{ esc_html(item.license.domain) }} + + + {{ item.license.status|capitalize }} + + + {% if item.license.expiresAt %} + {{ item.license.expiresAt|date('Y-m-d') }} + {% else %} + {{ __('Never') }} + {% endif %} +
+{% endif %} diff --git a/wc-licensed-product.php b/wc-licensed-product.php new file mode 100644 index 0000000..582696a --- /dev/null +++ b/wc-licensed-product.php @@ -0,0 +1,113 @@ + +
+

+ WC Licensed Product' + ); + ?> +

+
+ true] + ); + } + + // Run installation + Installer::activate(); +}); + +// Register deactivation hook +register_deactivation_hook(__FILE__, function (): void { + Installer::deactivate(); +});