Upgrade to PHPUnit 10, add PHPCS with WPCS compliance, add phpcs CI job
All checks were successful
Create Release Package / PHP Lint (push) Successful in 48s
Create Release Package / PHP CodeSniffer (push) Successful in 52s
Create Release Package / PHP Unit (push) Successful in 53s
Create Release Package / build-release (push) Successful in 59s

- Upgrade PHPUnit 9.6 → 10, update phpunit.xml.dist schema
- Add PHPCS 3.13 with WordPress-Extra + PHPCompatibilityWP standards
- PHPCBF auto-fix + manual fixes for full WPCS compliance
- Add phpcs job to release workflow (parallel with lint)
- Pin composer platform to PHP 8.3 to prevent incompatible dep locks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:25:02 +01:00
parent a7d6a57f01
commit dea1b055b2
16 changed files with 2006 additions and 1365 deletions

View File

@@ -28,7 +28,30 @@ jobs:
find includes -name "*.php" -print0 | xargs -0 -n1 php -l find includes -name "*.php" -print0 | xargs -0 -n1 php -l
find tests -name "*.php" -print0 | xargs -0 -n1 php -l find tests -name "*.php" -print0 | xargs -0 -n1 php -l
phpcs:
name: PHP CodeSniffer
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml
tools: composer:v2
- name: Install Composer dependencies
run: |
composer config platform.php 8.3.0
composer install --optimize-autoloader --no-interaction
- name: Run PHPCS
run: vendor/bin/phpcs
test: test:
name: PHP Unit
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: lint needs: lint
steps: steps:
@@ -51,7 +74,7 @@ jobs:
run: vendor/bin/phpunit --testdox run: vendor/bin/phpunit --testdox
build-release: build-release:
needs: test needs: [test, phpcs]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code

1
.gitignore vendored
View File

@@ -25,3 +25,4 @@ phpunit.xml
languages/*.mo languages/*.mo
.phpunit.result.cache .phpunit.result.cache
.phpunit.cache/

View File

@@ -12,15 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **PHPUnit test suite**: 57 unit tests covering all 6 core classes (ProductType, StockManager, CartHandler, Plugin, Admin/ProductData, Admin/Settings) - **PHPUnit test suite**: 57 unit tests covering all 6 core classes (ProductType, StockManager, CartHandler, Plugin, Admin/ProductData, Admin/Settings)
- **Brain Monkey + Mockery** for WordPress/WooCommerce function mocking without a full WP installation - **Brain Monkey + Mockery** for WordPress/WooCommerce function mocking without a full WP installation
- **WooCommerce class stubs** in `tests/stubs/` for classes extended by the plugin (WC_Product, WC_Settings_Page, etc.) - **WooCommerce class stubs** in `tests/stubs/` for classes extended by the plugin (WC_Product, WC_Settings_Page, etc.)
- **PHPCS** with WordPress-Extra and PHPCompatibilityWP coding standards (`phpcs.xml.dist`)
- **PHPCS job** in release workflow — coding standards must pass before release is built
- **PHP lint job** in release workflow — syntax-checks all PHP files before testing - **PHP lint job** in release workflow — syntax-checks all PHP files before testing
- **Test job** in release workflow — tests must pass before release package is built (`needs: test`) - **Test job** in release workflow — tests must pass before release package is built
- Testing section in README - Testing and linting sections in README and CLAUDE.md
### Changed ### Changed
- **PSR-4 refactored**: Renamed files to PascalCase (Product_Type → ProductType, etc.) and changed namespace from `WC_Composable_Product` to `Magdev\WcComposableProduct` - **PSR-4 refactored**: Renamed files to PascalCase (Product_Type → ProductType, etc.) and changed namespace from `WC_Composable_Product` to `Magdev\WcComposableProduct`
- Updated all cross-references in PHP files, main plugin file, composer.json, CSS/JS doc comments, and translation file source comments - Updated all cross-references in PHP files, main plugin file, composer.json, CSS/JS doc comments, and translation file source comments
- Release workflow now has three stages: lint → test → build-release - **PHPUnit upgraded** from 9.6 to 10 (Brain Monkey 2.7 supports both)
- **WPCS formatting** applied to all source files (tabs, Yoda conditions, strict `in_array`, `wp_json_encode`, long array syntax)
- Release workflow now has four stages: lint + phpcs (parallel) → test → build-release
- Composer platform pinned to PHP 8.3 to prevent incompatible dependency locks
## [1.3.0] - 2026-03-01 ## [1.3.0] - 2026-03-01

View File

@@ -18,7 +18,8 @@ This project is 100% AI-generated ("vibe-coded") using Claude.AI.
- **Styling:** Custom CSS - **Styling:** Custom CSS
- **Dependencies:** Composer - **Dependencies:** Composer
- **i18n:** WordPress i18n (.pot/.po/.mo), text domain: `wc-composable-product` - **i18n:** WordPress i18n (.pot/.po/.mo), text domain: `wc-composable-product`
- **Testing:** PHPUnit 9.6 + Brain Monkey 2.7 + Mockery 1.6 - **Testing:** PHPUnit 10 + Brain Monkey 2.7 + Mockery 1.6
- **Linting:** PHPCS 3.13 with WordPress-Extra + PHPCompatibilityWP
- **CI/CD:** Gitea Actions (`.gitea/workflows/release.yml`) - **CI/CD:** Gitea Actions (`.gitea/workflows/release.yml`)
## Project Structure ## Project Structure
@@ -55,6 +56,7 @@ wc-composable-product/
│ └── product-selector.twig # Frontend selection interface │ └── product-selector.twig # Frontend selection interface
├── vendor/ # Composer dependencies (gitignored, included in releases) ├── vendor/ # Composer dependencies (gitignored, included in releases)
├── composer.json ├── composer.json
├── phpcs.xml.dist # PHPCS configuration (WordPress-Extra + PHPCompatibilityWP)
├── phpunit.xml.dist # PHPUnit configuration ├── phpunit.xml.dist # PHPUnit configuration
└── wc-composable-product.php # Main plugin file └── wc-composable-product.php # Main plugin file
``` ```
@@ -140,11 +142,15 @@ Compile .po to .mo: `for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"
WordPress requires compiled .mo files — .po files alone are insufficient. WordPress requires compiled .mo files — .po files alone are insufficient.
## Testing ## Testing & Linting
Run unit tests: `vendor/bin/phpunit --testdox` Run unit tests: `vendor/bin/phpunit --testdox`
Tests use **Brain Monkey** to mock WordPress/WooCommerce functions without a full WP installation. WooCommerce classes (`WC_Product`, `WC_Settings_Page`, etc.) are provided as minimal stubs in `tests/stubs/` so PHP can parse `extends` declarations. The release workflow runs tests before building — a failing test blocks the release. Run coding standards check: `vendor/bin/phpcs`
Auto-fix coding standard violations: `vendor/bin/phpcbf`
Tests use **Brain Monkey** to mock WordPress/WooCommerce functions without a full WP installation. WooCommerce classes (`WC_Product`, `WC_Settings_Page`, etc.) are provided as minimal stubs in `tests/stubs/` so PHP can parse `extends` declarations. PHPCS uses the **WordPress-Extra** standard plus **PHPCompatibilityWP** for PHP version checks. The release workflow runs lint, phpcs, and tests before building — any failure blocks the release.
## Release Workflow ## Release Workflow

View File

@@ -20,8 +20,12 @@
}, },
"require-dev": { "require-dev": {
"brain/monkey": "^2.7", "brain/monkey": "^2.7",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"phpunit/phpunit": "^9.6" "phpcompatibility/phpcompatibility-wp": "*",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.7",
"wp-coding-standards/wpcs": "^3.0"
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
@@ -30,6 +34,12 @@
}, },
"config": { "config": {
"optimize-autoloader": true, "optimize-autoloader": true,
"sort-packages": true "sort-packages": true,
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
},
"platform": {
"php": "8.3.0"
}
} }
} }

1161
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
namespace Magdev\WcComposableProduct\Admin; namespace Magdev\WcComposableProduct\Admin;
defined('ABSPATH') || exit; defined( 'ABSPATH' ) || exit;
/** /**
* Product Data Tab Class * Product Data Tab Class
@@ -17,10 +17,10 @@ class ProductData {
* Constructor * Constructor
*/ */
public function __construct() { public function __construct() {
add_filter('woocommerce_product_data_tabs', [$this, 'add_product_data_tab']); add_filter( 'woocommerce_product_data_tabs', array( $this, 'add_product_data_tab' ) );
add_action('woocommerce_product_data_panels', [$this, 'add_product_data_panel']); add_action( 'woocommerce_product_data_panels', array( $this, 'add_product_data_panel' ) );
add_action('woocommerce_process_product_meta_composable', [$this, 'save_product_data']); add_action( 'woocommerce_process_product_meta_composable', array( $this, 'save_product_data' ) );
add_action('woocommerce_product_options_general_product_data', [$this, 'add_general_fields']); add_action( 'woocommerce_product_options_general_product_data', array( $this, 'add_general_fields' ) );
} }
/** /**
@@ -29,13 +29,13 @@ class ProductData {
* @param array $tabs Product data tabs * @param array $tabs Product data tabs
* @return array * @return array
*/ */
public function add_product_data_tab($tabs) { public function add_product_data_tab( $tabs ) {
$tabs['composable'] = [ $tabs['composable'] = array(
'label' => __('Composable Options', 'wc-composable-product'), 'label' => __( 'Composable Options', 'wc-composable-product' ),
'target' => 'composable_product_data', 'target' => 'composable_product_data',
'class' => ['show_if_composable'], 'class' => array( 'show_if_composable' ),
'priority' => 21, 'priority' => 21,
]; );
return $tabs; return $tabs;
} }
@@ -45,42 +45,48 @@ class ProductData {
public function add_general_fields() { public function add_general_fields() {
global $product_object; global $product_object;
if ($product_object && $product_object->get_type() === 'composable') { if ( $product_object && $product_object->get_type() === 'composable' ) {
echo '<div class="options_group show_if_composable">'; echo '<div class="options_group show_if_composable">';
woocommerce_wp_text_input([ woocommerce_wp_text_input(
array(
'id' => '_composable_selection_limit', 'id' => '_composable_selection_limit',
'label' => __('Selection Limit', 'wc-composable-product'), 'label' => __( 'Selection Limit', 'wc-composable-product' ),
'description' => __('Maximum number of items customers can select. Leave empty to use global default.', 'wc-composable-product'), 'description' => __( 'Maximum number of items customers can select. Leave empty to use global default.', 'wc-composable-product' ),
'desc_tip' => true, 'desc_tip' => true,
'type' => 'number', 'type' => 'number',
'custom_attributes' => [ 'custom_attributes' => array(
'min' => '1', 'min' => '1',
'step' => '1', 'step' => '1',
], ),
]); )
);
woocommerce_wp_select([ woocommerce_wp_select(
array(
'id' => '_composable_pricing_mode', 'id' => '_composable_pricing_mode',
'label' => __('Pricing Mode', 'wc-composable-product'), 'label' => __( 'Pricing Mode', 'wc-composable-product' ),
'description' => __('How to calculate the price.', 'wc-composable-product'), 'description' => __( 'How to calculate the price.', 'wc-composable-product' ),
'desc_tip' => true, 'desc_tip' => true,
'options' => [ 'options' => array(
'' => __('Use global default', 'wc-composable-product'), '' => __( 'Use global default', 'wc-composable-product' ),
'sum' => __('Sum of selected products', 'wc-composable-product'), 'sum' => __( 'Sum of selected products', 'wc-composable-product' ),
'fixed' => __('Fixed price', 'wc-composable-product'), 'fixed' => __( 'Fixed price', 'wc-composable-product' ),
], ),
]); )
);
woocommerce_wp_text_input([ woocommerce_wp_text_input(
array(
'id' => '_regular_price', 'id' => '_regular_price',
'label' => __('Fixed Price', 'wc-composable-product') . ' (' . get_woocommerce_currency_symbol() . ')', 'label' => __( 'Fixed Price', 'wc-composable-product' ) . ' (' . get_woocommerce_currency_symbol() . ')',
'description' => __('Enter the fixed price for this composable product.', 'wc-composable-product'), 'description' => __( 'Enter the fixed price for this composable product.', 'wc-composable-product' ),
'desc_tip' => true, 'desc_tip' => true,
'type' => 'text', 'type' => 'text',
'data_type' => 'price', 'data_type' => 'price',
'wrapper_class' => 'composable_fixed_price_field', 'wrapper_class' => 'composable_fixed_price_field',
]); )
);
echo '</div>'; echo '</div>';
} }
@@ -95,85 +101,103 @@ class ProductData {
<div id="composable_product_data" class="panel woocommerce_options_panel hidden"> <div id="composable_product_data" class="panel woocommerce_options_panel hidden">
<div class="options_group"> <div class="options_group">
<?php <?php
woocommerce_wp_select([ woocommerce_wp_select(
array(
'id' => '_composable_include_unpublished', 'id' => '_composable_include_unpublished',
'label' => __('Include Non-Public Products', 'wc-composable-product'), 'label' => __( 'Include Non-Public Products', 'wc-composable-product' ),
'description' => __('Allow draft and private products in the selection. Useful when products should only be sold as part of a composition.', 'wc-composable-product'), 'description' => __( 'Allow draft and private products in the selection. Useful when products should only be sold as part of a composition.', 'wc-composable-product' ),
'desc_tip' => true, 'desc_tip' => true,
'options' => [ 'options' => array(
'' => __('Use global default', 'wc-composable-product'), '' => __( 'Use global default', 'wc-composable-product' ),
'yes' => __('Yes', 'wc-composable-product'), 'yes' => __( 'Yes', 'wc-composable-product' ),
'no' => __('No', 'wc-composable-product'), 'no' => __( 'No', 'wc-composable-product' ),
], ),
'value' => get_post_meta($post->ID, '_composable_include_unpublished', true) ?: '', 'value' => get_post_meta( $post->ID, '_composable_include_unpublished', true ) ? get_post_meta( $post->ID, '_composable_include_unpublished', true ) : '',
]); )
);
woocommerce_wp_select([ woocommerce_wp_select(
array(
'id' => '_composable_criteria_type', 'id' => '_composable_criteria_type',
'label' => __('Selection Criteria', 'wc-composable-product'), 'label' => __( 'Selection Criteria', 'wc-composable-product' ),
'description' => __('How to select available products.', 'wc-composable-product'), 'description' => __( 'How to select available products.', 'wc-composable-product' ),
'desc_tip' => true, 'desc_tip' => true,
'options' => [ 'options' => array(
'category' => __('By Category', 'wc-composable-product'), 'category' => __( 'By Category', 'wc-composable-product' ),
'tag' => __('By Tag', 'wc-composable-product'), 'tag' => __( 'By Tag', 'wc-composable-product' ),
'sku' => __('By SKU', 'wc-composable-product'), 'sku' => __( 'By SKU', 'wc-composable-product' ),
], ),
'value' => get_post_meta($post->ID, '_composable_criteria_type', true) ?: 'category', 'value' => get_post_meta( $post->ID, '_composable_criteria_type', true ) ? get_post_meta( $post->ID, '_composable_criteria_type', true ) : 'category',
]); )
);
?> ?>
</div> </div>
<div class="options_group composable_criteria_group" id="composable_criteria_category"> <div class="options_group composable_criteria_group" id="composable_criteria_category">
<p class="form-field"> <p class="form-field">
<label for="_composable_categories"><?php esc_html_e('Select Categories', 'wc-composable-product'); ?></label> <label for="_composable_categories"><?php esc_html_e( 'Select Categories', 'wc-composable-product' ); ?></label>
<select id="_composable_categories" name="_composable_categories[]" class="wc-enhanced-select" multiple="multiple" style="width: 50%;"> <select id="_composable_categories" name="_composable_categories[]" class="wc-enhanced-select" multiple="multiple" style="width: 50%;">
<?php <?php
$selected_categories = get_post_meta($post->ID, '_composable_categories', true) ?: []; $selected_categories = get_post_meta( $post->ID, '_composable_categories', true );
$categories = get_terms(['taxonomy' => 'product_cat', 'hide_empty' => false]); $selected_categories = $selected_categories ? $selected_categories : array();
foreach ($categories as $category) { $categories = get_terms(
array(
'taxonomy' => 'product_cat',
'hide_empty' => false,
)
);
foreach ( $categories as $category ) {
printf( printf(
'<option value="%s" %s>%s</option>', '<option value="%s" %s>%s</option>',
esc_attr($category->term_id), esc_attr( $category->term_id ),
selected(in_array($category->term_id, (array) $selected_categories), true, false), selected( in_array( $category->term_id, (array) $selected_categories, true ), true, false ),
esc_html($category->name) esc_html( $category->name )
); );
} }
?> ?>
</select> </select>
<span class="description"><?php esc_html_e('Select product categories to include.', 'wc-composable-product'); ?></span> <span class="description"><?php esc_html_e( 'Select product categories to include.', 'wc-composable-product' ); ?></span>
</p> </p>
</div> </div>
<div class="options_group composable_criteria_group" id="composable_criteria_tag" style="display: none;"> <div class="options_group composable_criteria_group" id="composable_criteria_tag" style="display: none;">
<p class="form-field"> <p class="form-field">
<label for="_composable_tags"><?php esc_html_e('Select Tags', 'wc-composable-product'); ?></label> <label for="_composable_tags"><?php esc_html_e( 'Select Tags', 'wc-composable-product' ); ?></label>
<select id="_composable_tags" name="_composable_tags[]" class="wc-enhanced-select" multiple="multiple" style="width: 50%;"> <select id="_composable_tags" name="_composable_tags[]" class="wc-enhanced-select" multiple="multiple" style="width: 50%;">
<?php <?php
$selected_tags = get_post_meta($post->ID, '_composable_tags', true) ?: []; $selected_tags = get_post_meta( $post->ID, '_composable_tags', true );
$tags = get_terms(['taxonomy' => 'product_tag', 'hide_empty' => false]); $selected_tags = $selected_tags ? $selected_tags : array();
foreach ($tags as $tag) { $tags = get_terms(
array(
'taxonomy' => 'product_tag',
'hide_empty' => false,
)
);
foreach ( $tags as $tag ) {
printf( printf(
'<option value="%s" %s>%s</option>', '<option value="%s" %s>%s</option>',
esc_attr($tag->term_id), esc_attr( $tag->term_id ),
selected(in_array($tag->term_id, (array) $selected_tags), true, false), selected( in_array( $tag->term_id, (array) $selected_tags, true ), true, false ),
esc_html($tag->name) esc_html( $tag->name )
); );
} }
?> ?>
</select> </select>
<span class="description"><?php esc_html_e('Select product tags to include.', 'wc-composable-product'); ?></span> <span class="description"><?php esc_html_e( 'Select product tags to include.', 'wc-composable-product' ); ?></span>
</p> </p>
</div> </div>
<div class="options_group composable_criteria_group" id="composable_criteria_sku" style="display: none;"> <div class="options_group composable_criteria_group" id="composable_criteria_sku" style="display: none;">
<?php <?php
woocommerce_wp_textarea_input([ woocommerce_wp_textarea_input(
array(
'id' => '_composable_skus', 'id' => '_composable_skus',
'label' => __('Product SKUs', 'wc-composable-product'), 'label' => __( 'Product SKUs', 'wc-composable-product' ),
'description' => __('Enter product SKUs separated by commas.', 'wc-composable-product'), 'description' => __( 'Enter product SKUs separated by commas.', 'wc-composable-product' ),
'desc_tip' => true, 'desc_tip' => true,
'placeholder' => __('SKU-1, SKU-2, SKU-3', 'wc-composable-product'), 'placeholder' => __( 'SKU-1, SKU-2, SKU-3', 'wc-composable-product' ),
]); )
);
?> ?>
</div> </div>
</div> </div>
@@ -185,33 +209,37 @@ class ProductData {
* *
* @param int $post_id Post ID * @param int $post_id Post ID
*/ */
public function save_product_data($post_id) { public function save_product_data( $post_id ) {
// Save selection limit // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified by WooCommerce in woocommerce_process_product_meta.
$selection_limit = isset($_POST['_composable_selection_limit']) ? absint($_POST['_composable_selection_limit']) : '';
update_post_meta($post_id, '_composable_selection_limit', $selection_limit);
// Save pricing mode // Save selection limit.
$pricing_mode = isset($_POST['_composable_pricing_mode']) ? sanitize_text_field($_POST['_composable_pricing_mode']) : ''; $selection_limit = isset( $_POST['_composable_selection_limit'] ) ? absint( $_POST['_composable_selection_limit'] ) : '';
update_post_meta($post_id, '_composable_pricing_mode', $pricing_mode); update_post_meta( $post_id, '_composable_selection_limit', $selection_limit );
// Save include unpublished // Save pricing mode.
$include_unpublished = isset($_POST['_composable_include_unpublished']) ? sanitize_text_field($_POST['_composable_include_unpublished']) : ''; $pricing_mode = isset( $_POST['_composable_pricing_mode'] ) ? sanitize_text_field( $_POST['_composable_pricing_mode'] ) : '';
update_post_meta($post_id, '_composable_include_unpublished', $include_unpublished); update_post_meta( $post_id, '_composable_pricing_mode', $pricing_mode );
// Save criteria type // Save include unpublished.
$criteria_type = isset($_POST['_composable_criteria_type']) ? sanitize_text_field($_POST['_composable_criteria_type']) : 'category'; $include_unpublished = isset( $_POST['_composable_include_unpublished'] ) ? sanitize_text_field( $_POST['_composable_include_unpublished'] ) : '';
update_post_meta($post_id, '_composable_criteria_type', $criteria_type); update_post_meta( $post_id, '_composable_include_unpublished', $include_unpublished );
// Save categories // Save criteria type.
$categories = isset($_POST['_composable_categories']) ? array_map('absint', $_POST['_composable_categories']) : []; $criteria_type = isset( $_POST['_composable_criteria_type'] ) ? sanitize_text_field( $_POST['_composable_criteria_type'] ) : 'category';
update_post_meta($post_id, '_composable_categories', $categories); update_post_meta( $post_id, '_composable_criteria_type', $criteria_type );
// Save tags // Save categories.
$tags = isset($_POST['_composable_tags']) ? array_map('absint', $_POST['_composable_tags']) : []; $categories = isset( $_POST['_composable_categories'] ) ? array_map( 'absint', $_POST['_composable_categories'] ) : array();
update_post_meta($post_id, '_composable_tags', $tags); update_post_meta( $post_id, '_composable_categories', $categories );
// Save SKUs // Save tags.
$skus = isset($_POST['_composable_skus']) ? sanitize_textarea_field($_POST['_composable_skus']) : ''; $tags = isset( $_POST['_composable_tags'] ) ? array_map( 'absint', $_POST['_composable_tags'] ) : array();
update_post_meta($post_id, '_composable_skus', $skus); update_post_meta( $post_id, '_composable_tags', $tags );
// Save SKUs.
$skus = isset( $_POST['_composable_skus'] ) ? sanitize_textarea_field( $_POST['_composable_skus'] ) : '';
update_post_meta( $post_id, '_composable_skus', $skus );
// phpcs:enable WordPress.Security.NonceVerification.Missing
} }
} }

View File

@@ -7,7 +7,7 @@
namespace Magdev\WcComposableProduct\Admin; namespace Magdev\WcComposableProduct\Admin;
defined('ABSPATH') || exit; defined( 'ABSPATH' ) || exit;
/** /**
* Settings class * Settings class
@@ -18,7 +18,7 @@ class Settings extends \WC_Settings_Page {
*/ */
public function __construct() { public function __construct() {
$this->id = 'composable_products'; $this->id = 'composable_products';
$this->label = __('Composable Products', 'wc-composable-product'); $this->label = __( 'Composable Products', 'wc-composable-product' );
parent::__construct(); parent::__construct();
} }
@@ -29,72 +29,72 @@ class Settings extends \WC_Settings_Page {
* @return array * @return array
*/ */
public function get_settings() { public function get_settings() {
$settings = [ $settings = array(
[ array(
'title' => __('Composable Products Settings', 'wc-composable-product'), 'title' => __( 'Composable Products Settings', 'wc-composable-product' ),
'type' => 'title', 'type' => 'title',
'desc' => __('Configure default settings for composable products.', 'wc-composable-product'), 'desc' => __( 'Configure default settings for composable products.', 'wc-composable-product' ),
'id' => 'wc_composable_settings', 'id' => 'wc_composable_settings',
], ),
[ array(
'title' => __('Default Selection Limit', 'wc-composable-product'), 'title' => __( 'Default Selection Limit', 'wc-composable-product' ),
'desc' => __('Default number of items customers can select.', 'wc-composable-product'), 'desc' => __( 'Default number of items customers can select.', 'wc-composable-product' ),
'id' => 'wc_composable_default_limit', 'id' => 'wc_composable_default_limit',
'type' => 'number', 'type' => 'number',
'default' => '5', 'default' => '5',
'custom_attributes' => [ 'custom_attributes' => array(
'min' => '1', 'min' => '1',
'step' => '1', 'step' => '1',
], ),
'desc_tip' => true, 'desc_tip' => true,
], ),
[ array(
'title' => __('Default Pricing Mode', 'wc-composable-product'), 'title' => __( 'Default Pricing Mode', 'wc-composable-product' ),
'desc' => __('How to calculate the price of composable products.', 'wc-composable-product'), 'desc' => __( 'How to calculate the price of composable products.', 'wc-composable-product' ),
'id' => 'wc_composable_default_pricing', 'id' => 'wc_composable_default_pricing',
'type' => 'select', 'type' => 'select',
'default' => 'sum', 'default' => 'sum',
'options' => [ 'options' => array(
'sum' => __('Sum of selected products', 'wc-composable-product'), 'sum' => __( 'Sum of selected products', 'wc-composable-product' ),
'fixed' => __('Fixed price', 'wc-composable-product'), 'fixed' => __( 'Fixed price', 'wc-composable-product' ),
], ),
'desc_tip' => true, 'desc_tip' => true,
], ),
[ array(
'title' => __('Include Non-Public Products', 'wc-composable-product'), 'title' => __( 'Include Non-Public Products', 'wc-composable-product' ),
'desc' => __('Allow draft and private products to appear in composable product selections. Useful when products should only be sold as part of a composition, not individually.', 'wc-composable-product'), 'desc' => __( 'Allow draft and private products to appear in composable product selections. Useful when products should only be sold as part of a composition, not individually.', 'wc-composable-product' ),
'id' => 'wc_composable_include_unpublished', 'id' => 'wc_composable_include_unpublished',
'type' => 'checkbox', 'type' => 'checkbox',
'default' => 'no', 'default' => 'no',
], ),
[ array(
'title' => __('Show Product Images', 'wc-composable-product'), 'title' => __( 'Show Product Images', 'wc-composable-product' ),
'desc' => __('Display product images in the selection interface.', 'wc-composable-product'), 'desc' => __( 'Display product images in the selection interface.', 'wc-composable-product' ),
'id' => 'wc_composable_show_images', 'id' => 'wc_composable_show_images',
'type' => 'checkbox', 'type' => 'checkbox',
'default' => 'yes', 'default' => 'yes',
], ),
[ array(
'title' => __('Show Product Prices', 'wc-composable-product'), 'title' => __( 'Show Product Prices', 'wc-composable-product' ),
'desc' => __('Display individual product prices in the selection interface.', 'wc-composable-product'), 'desc' => __( 'Display individual product prices in the selection interface.', 'wc-composable-product' ),
'id' => 'wc_composable_show_prices', 'id' => 'wc_composable_show_prices',
'type' => 'checkbox', 'type' => 'checkbox',
'default' => 'yes', 'default' => 'yes',
], ),
[ array(
'title' => __('Show Total Price', 'wc-composable-product'), 'title' => __( 'Show Total Price', 'wc-composable-product' ),
'desc' => __('Display the total price as customers make selections.', 'wc-composable-product'), 'desc' => __( 'Display the total price as customers make selections.', 'wc-composable-product' ),
'id' => 'wc_composable_show_total', 'id' => 'wc_composable_show_total',
'type' => 'checkbox', 'type' => 'checkbox',
'default' => 'yes', 'default' => 'yes',
], ),
[ array(
'type' => 'sectionend', 'type' => 'sectionend',
'id' => 'wc_composable_settings', 'id' => 'wc_composable_settings',
], ),
]; );
return apply_filters('wc_composable_settings', $settings); return apply_filters( 'wc_composable_settings', $settings );
} }
/** /**
@@ -102,7 +102,7 @@ class Settings extends \WC_Settings_Page {
*/ */
public function output() { public function output() {
$settings = $this->get_settings(); $settings = $this->get_settings();
\WC_Admin_Settings::output_fields($settings); \WC_Admin_Settings::output_fields( $settings );
} }
/** /**
@@ -110,6 +110,6 @@ class Settings extends \WC_Settings_Page {
*/ */
public function save() { public function save() {
$settings = $this->get_settings(); $settings = $this->get_settings();
\WC_Admin_Settings::save_fields($settings); \WC_Admin_Settings::save_fields( $settings );
} }
} }

View File

@@ -7,7 +7,7 @@
namespace Magdev\WcComposableProduct; namespace Magdev\WcComposableProduct;
defined('ABSPATH') || exit; defined( 'ABSPATH' ) || exit;
/** /**
* Cart Handler Class * Cart Handler Class
@@ -28,14 +28,14 @@ class CartHandler {
public function __construct() { public function __construct() {
$this->stock_manager = new StockManager(); $this->stock_manager = new StockManager();
add_filter('woocommerce_add_to_cart_validation', [$this, 'validate_add_to_cart'], 10, 3); add_filter( 'woocommerce_add_to_cart_validation', array( $this, 'validate_add_to_cart' ), 10, 3 );
add_filter('woocommerce_add_cart_item_data', [$this, 'add_cart_item_data'], 10, 2); add_filter( 'woocommerce_add_cart_item_data', array( $this, 'add_cart_item_data' ), 10, 2 );
add_filter('woocommerce_get_cart_item_from_session', [$this, 'get_cart_item_from_session'], 10, 2); add_filter( 'woocommerce_get_cart_item_from_session', array( $this, 'get_cart_item_from_session' ), 10, 2 );
add_filter('woocommerce_get_item_data', [$this, 'display_cart_item_data'], 10, 2); add_filter( 'woocommerce_get_item_data', array( $this, 'display_cart_item_data' ), 10, 2 );
add_action('woocommerce_before_calculate_totals', [$this, 'calculate_cart_item_price']); add_action( 'woocommerce_before_calculate_totals', array( $this, 'calculate_cart_item_price' ) );
add_action('woocommerce_single_product_summary', [$this, 'render_product_selector'], 25); add_action( 'woocommerce_single_product_summary', array( $this, 'render_product_selector' ), 25 );
add_action('woocommerce_checkout_create_order_line_item', [$this->stock_manager, 'store_selected_products_in_order'], 10, 3); add_action( 'woocommerce_checkout_create_order_line_item', array( $this->stock_manager, 'store_selected_products_in_order' ), 10, 3 );
add_filter('woocommerce_is_purchasable', [$this, 'hide_default_add_to_cart'], 10, 2); add_filter( 'woocommerce_is_purchasable', array( $this, 'hide_default_add_to_cart' ), 10, 2 );
} }
/** /**
@@ -45,8 +45,8 @@ class CartHandler {
* @param \WC_Product $product Product object * @param \WC_Product $product Product object
* @return bool * @return bool
*/ */
public function hide_default_add_to_cart($is_purchasable, $product) { public function hide_default_add_to_cart( $is_purchasable, $product ) {
if ($product && $product->get_type() === 'composable') { if ( $product && $product->get_type() === 'composable' ) {
return false; return false;
} }
return $is_purchasable; return $is_purchasable;
@@ -58,8 +58,8 @@ class CartHandler {
public function render_product_selector() { public function render_product_selector() {
global $product; global $product;
if ($product && $product->get_type() === 'composable') { if ( $product && $product->get_type() === 'composable' ) {
ProductSelector::render($product); ProductSelector::render( $product );
} }
} }
@@ -71,51 +71,56 @@ class CartHandler {
* @param int $quantity Quantity * @param int $quantity Quantity
* @return bool * @return bool
*/ */
public function validate_add_to_cart($passed, $product_id, $quantity) { public function validate_add_to_cart( $passed, $product_id, $quantity ) {
$product = wc_get_product($product_id); $product = wc_get_product( $product_id );
if (!$product || $product->get_type() !== 'composable') { if ( ! $product || $product->get_type() !== 'composable' ) {
return $passed; return $passed;
} }
// Check if selected products are provided // Check if selected products are provided.
if (!isset($_POST['composable_products']) || empty($_POST['composable_products'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by WooCommerce add-to-cart handler.
wc_add_notice(__('Please select at least one product.', 'wc-composable-product'), 'error'); if ( ! isset( $_POST['composable_products'] ) || empty( $_POST['composable_products'] ) ) {
wc_add_notice( __( 'Please select at least one product.', 'wc-composable-product' ), 'error' );
return false; return false;
} }
$selected_products = array_map('absint', $_POST['composable_products']); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by WooCommerce add-to-cart handler.
$selected_products = array_map( 'absint', $_POST['composable_products'] );
$selection_limit = $product->get_selection_limit(); $selection_limit = $product->get_selection_limit();
// Validate selection limit // Validate selection limit
if (count($selected_products) > $selection_limit) { if ( count( $selected_products ) > $selection_limit ) {
/* translators: %d: selection limit */ /* translators: %d: selection limit */
wc_add_notice(sprintf(__('You can select a maximum of %d products.', 'wc-composable-product'), $selection_limit), 'error'); wc_add_notice( sprintf( __( 'You can select a maximum of %d products.', 'wc-composable-product' ), $selection_limit ), 'error' );
return false; return false;
} }
if (count($selected_products) === 0) { if ( count( $selected_products ) === 0 ) {
wc_add_notice(__('Please select at least one product.', 'wc-composable-product'), 'error'); wc_add_notice( __( 'Please select at least one product.', 'wc-composable-product' ), 'error' );
return false; return false;
} }
// Validate that selected products are valid // Validate that selected products are valid
$available_products = $product->get_available_products(); $available_products = $product->get_available_products();
$available_ids = array_map(function($p) { $available_ids = array_map(
function ( $p ) {
return $p->get_id(); return $p->get_id();
}, $available_products); },
$available_products
);
foreach ($selected_products as $selected_id) { foreach ( $selected_products as $selected_id ) {
if (!in_array($selected_id, $available_ids)) { if ( ! in_array( $selected_id, $available_ids, true ) ) {
wc_add_notice(__('One or more selected products are not available.', 'wc-composable-product'), 'error'); wc_add_notice( __( 'One or more selected products are not available.', 'wc-composable-product' ), 'error' );
return false; return false;
} }
} }
// Validate stock availability // Validate stock availability
$stock_validation = $this->stock_manager->validate_stock_availability($selected_products, $quantity); $stock_validation = $this->stock_manager->validate_stock_availability( $selected_products, $quantity );
if ($stock_validation !== true) { if ( true !== $stock_validation ) {
wc_add_notice($stock_validation, 'error'); wc_add_notice( $stock_validation, 'error' );
return false; return false;
} }
@@ -129,19 +134,21 @@ class CartHandler {
* @param int $product_id Product ID * @param int $product_id Product ID
* @return array * @return array
*/ */
public function add_cart_item_data($cart_item_data, $product_id) { public function add_cart_item_data( $cart_item_data, $product_id ) {
$product = wc_get_product($product_id); $product = wc_get_product( $product_id );
if (!$product || $product->get_type() !== 'composable') { if ( ! $product || $product->get_type() !== 'composable' ) {
return $cart_item_data; return $cart_item_data;
} }
if (isset($_POST['composable_products']) && !empty($_POST['composable_products'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by WooCommerce add-to-cart handler.
$selected_products = array_map('absint', $_POST['composable_products']); if ( isset( $_POST['composable_products'] ) && ! empty( $_POST['composable_products'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by WooCommerce add-to-cart handler.
$selected_products = array_map( 'absint', $_POST['composable_products'] );
$cart_item_data['composable_products'] = $selected_products; $cart_item_data['composable_products'] = $selected_products;
// Make cart item unique // Make cart item unique.
$cart_item_data['unique_key'] = md5(json_encode($selected_products) . time()); $cart_item_data['unique_key'] = md5( wp_json_encode( $selected_products ) . time() );
} }
return $cart_item_data; return $cart_item_data;
@@ -154,8 +161,8 @@ class CartHandler {
* @param array $values Values from session * @param array $values Values from session
* @return array * @return array
*/ */
public function get_cart_item_from_session($cart_item, $values) { public function get_cart_item_from_session( $cart_item, $values ) {
if (isset($values['composable_products'])) { if ( isset( $values['composable_products'] ) ) {
$cart_item['composable_products'] = $values['composable_products']; $cart_item['composable_products'] = $values['composable_products'];
} }
@@ -169,21 +176,21 @@ class CartHandler {
* @param array $cart_item Cart item * @param array $cart_item Cart item
* @return array * @return array
*/ */
public function display_cart_item_data($item_data, $cart_item) { public function display_cart_item_data( $item_data, $cart_item ) {
if (isset($cart_item['composable_products']) && !empty($cart_item['composable_products'])) { if ( isset( $cart_item['composable_products'] ) && ! empty( $cart_item['composable_products'] ) ) {
$product_names = []; $product_names = array();
foreach ($cart_item['composable_products'] as $product_id) { foreach ( $cart_item['composable_products'] as $product_id ) {
$product = wc_get_product($product_id); $product = wc_get_product( $product_id );
if ($product) { if ( $product ) {
$product_names[] = $product->get_name(); $product_names[] = $product->get_name();
} }
} }
if (!empty($product_names)) { if ( ! empty( $product_names ) ) {
$item_data[] = [ $item_data[] = array(
'key' => __('Selected Products', 'wc-composable-product'), 'key' => __( 'Selected Products', 'wc-composable-product' ),
'value' => implode(', ', $product_names), 'value' => implode( ', ', $product_names ),
]; );
} }
} }
@@ -195,23 +202,23 @@ class CartHandler {
* *
* @param \WC_Cart $cart Cart object * @param \WC_Cart $cart Cart object
*/ */
public function calculate_cart_item_price($cart) { public function calculate_cart_item_price( $cart ) {
if (is_admin() && !defined('DOING_AJAX')) { if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return; return;
} }
// Use static flag to prevent multiple executions within the same request // Use static flag to prevent multiple executions within the same request
static $already_calculated = false; static $already_calculated = false;
if ($already_calculated) { if ( $already_calculated ) {
return; return;
} }
foreach ($cart->get_cart() as $cart_item_key => $cart_item) { foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
if (isset($cart_item['data']) && $cart_item['data']->get_type() === 'composable') { if ( isset( $cart_item['data'] ) && $cart_item['data']->get_type() === 'composable' ) {
if (isset($cart_item['composable_products'])) { if ( isset( $cart_item['composable_products'] ) ) {
$product = $cart_item['data']; $product = $cart_item['data'];
$price = $product->calculate_composed_price($cart_item['composable_products']); $price = $product->calculate_composed_price( $cart_item['composable_products'] );
$cart_item['data']->set_price($price); $cart_item['data']->set_price( $price );
} }
} }
} }

View File

@@ -7,7 +7,7 @@
namespace Magdev\WcComposableProduct; namespace Magdev\WcComposableProduct;
defined('ABSPATH') || exit; defined( 'ABSPATH' ) || exit;
/** /**
* Main plugin class - Singleton pattern * Main plugin class - Singleton pattern
@@ -35,7 +35,7 @@ class Plugin {
* @return Plugin * @return Plugin
*/ */
public static function instance() { public static function instance() {
if (is_null(self::$instance)) { if ( is_null( self::$instance ) ) {
self::$instance = new self(); self::$instance = new self();
} }
return self::$instance; return self::$instance;
@@ -55,41 +55,50 @@ class Plugin {
*/ */
private function init_hooks() { private function init_hooks() {
// Register product type // Register product type
add_filter('product_type_selector', [$this, 'add_product_type']); add_filter( 'product_type_selector', array( $this, 'add_product_type' ) );
add_filter('woocommerce_product_class', [$this, 'product_class'], 10, 2); add_filter( 'woocommerce_product_class', array( $this, 'product_class' ), 10, 2 );
// Enqueue scripts and styles // Enqueue scripts and styles
add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_scripts']); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_scripts' ) );
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) );
// Admin settings // Admin settings
add_filter('woocommerce_get_settings_pages', [$this, 'add_settings_page']); add_filter( 'woocommerce_get_settings_pages', array( $this, 'add_settings_page' ) );
} }
/** /**
* Initialize Twig template engine * Initialize Twig template engine
*/ */
private function init_twig() { private function init_twig() {
$loader = new \Twig\Loader\FilesystemLoader(WC_COMPOSABLE_PRODUCT_PATH . 'templates'); $loader = new \Twig\Loader\FilesystemLoader( WC_COMPOSABLE_PRODUCT_PATH . 'templates' );
$this->twig = new \Twig\Environment($loader, [ $this->twig = new \Twig\Environment(
$loader,
array(
'cache' => WC_COMPOSABLE_PRODUCT_PATH . 'cache', 'cache' => WC_COMPOSABLE_PRODUCT_PATH . 'cache',
'auto_reload' => true, 'auto_reload' => true,
'debug' => defined('WP_DEBUG') && WP_DEBUG, 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG,
]); )
);
// Add WordPress functions to Twig // Add WordPress functions to Twig
$this->twig->addFunction(new \Twig\TwigFunction('__', function($text) { $this->twig->addFunction(
return __($text, 'wc-composable-product'); new \Twig\TwigFunction(
})); '__',
$this->twig->addFunction(new \Twig\TwigFunction('esc_html', 'esc_html')); function ( $text ) {
$this->twig->addFunction(new \Twig\TwigFunction('esc_attr', 'esc_attr')); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- dynamic Twig template strings.
$this->twig->addFunction(new \Twig\TwigFunction('esc_url', 'esc_url')); return __( $text, 'wc-composable-product' );
$this->twig->addFunction(new \Twig\TwigFunction('wc_price', 'wc_price')); }
)
);
$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( 'wc_price', 'wc_price' ) );
// Add WordPress escaping functions as Twig filters // Add WordPress escaping functions as Twig filters
$this->twig->addFilter(new \Twig\TwigFilter('esc_html', 'esc_html')); $this->twig->addFilter( new \Twig\TwigFilter( 'esc_html', 'esc_html' ) );
$this->twig->addFilter(new \Twig\TwigFilter('esc_attr', 'esc_attr')); $this->twig->addFilter( new \Twig\TwigFilter( 'esc_attr', 'esc_attr' ) );
$this->twig->addFilter(new \Twig\TwigFilter('esc_url', 'esc_url')); $this->twig->addFilter( new \Twig\TwigFilter( 'esc_url', 'esc_url' ) );
} }
/** /**
@@ -115,8 +124,8 @@ class Plugin {
* @param array $types Product types * @param array $types Product types
* @return array * @return array
*/ */
public function add_product_type($types) { public function add_product_type( $types ) {
$types['composable'] = __('Composable product', 'wc-composable-product'); $types['composable'] = __( 'Composable product', 'wc-composable-product' );
return $types; return $types;
} }
@@ -127,8 +136,8 @@ class Plugin {
* @param string $product_type Product type * @param string $product_type Product type
* @return string * @return string
*/ */
public function product_class($classname, $product_type) { public function product_class( $classname, $product_type ) {
if ($product_type === 'composable') { if ( 'composable' === $product_type ) {
$classname = 'Magdev\WcComposableProduct\ProductType'; $classname = 'Magdev\WcComposableProduct\ProductType';
} }
return $classname; return $classname;
@@ -138,59 +147,63 @@ class Plugin {
* Enqueue frontend scripts and styles * Enqueue frontend scripts and styles
*/ */
public function enqueue_frontend_scripts() { public function enqueue_frontend_scripts() {
if (is_product()) { if ( is_product() ) {
wp_enqueue_style( wp_enqueue_style(
'wc-composable-product', 'wc-composable-product',
WC_COMPOSABLE_PRODUCT_URL . 'assets/css/frontend.css', WC_COMPOSABLE_PRODUCT_URL . 'assets/css/frontend.css',
[], array(),
WC_COMPOSABLE_PRODUCT_VERSION WC_COMPOSABLE_PRODUCT_VERSION
); );
wp_enqueue_script( wp_enqueue_script(
'wc-composable-product', 'wc-composable-product',
WC_COMPOSABLE_PRODUCT_URL . 'assets/js/frontend.js', WC_COMPOSABLE_PRODUCT_URL . 'assets/js/frontend.js',
['jquery'], array( 'jquery' ),
WC_COMPOSABLE_PRODUCT_VERSION, WC_COMPOSABLE_PRODUCT_VERSION,
true true
); );
wp_localize_script('wc-composable-product', 'wcComposableProduct', [ wp_localize_script(
'ajax_url' => admin_url('admin-ajax.php'), 'wc-composable-product',
'nonce' => wp_create_nonce('wc_composable_product_nonce'), 'wcComposableProduct',
'i18n' => [ array(
'select_items' => __('Please select items', 'wc-composable-product'), 'ajax_url' => admin_url( 'admin-ajax.php' ),
'max_items' => __('Maximum items selected', 'wc-composable-product'), 'nonce' => wp_create_nonce( 'wc_composable_product_nonce' ),
'min_items' => __('Please select at least one item', 'wc-composable-product'), 'i18n' => array(
], 'select_items' => __( 'Please select items', 'wc-composable-product' ),
'price_format' => [ 'max_items' => __( 'Maximum items selected', 'wc-composable-product' ),
'min_items' => __( 'Please select at least one item', 'wc-composable-product' ),
),
'price_format' => array(
'currency_symbol' => get_woocommerce_currency_symbol(), 'currency_symbol' => get_woocommerce_currency_symbol(),
'decimal_separator' => wc_get_price_decimal_separator(), 'decimal_separator' => wc_get_price_decimal_separator(),
'thousand_separator' => wc_get_price_thousand_separator(), 'thousand_separator' => wc_get_price_thousand_separator(),
'decimals' => wc_get_price_decimals(), 'decimals' => wc_get_price_decimals(),
'price_format' => get_woocommerce_price_format(), 'price_format' => get_woocommerce_price_format(),
], ),
]); )
);
} }
} }
/** /**
* Enqueue admin scripts and styles * Enqueue admin scripts and styles
*/ */
public function enqueue_admin_scripts($hook) { public function enqueue_admin_scripts( $hook ) {
if ('post.php' === $hook || 'post-new.php' === $hook) { if ( 'post.php' === $hook || 'post-new.php' === $hook ) {
global $post_type; global $post_type;
if ('product' === $post_type) { if ( 'product' === $post_type ) {
wp_enqueue_style( wp_enqueue_style(
'wc-composable-product-admin', 'wc-composable-product-admin',
WC_COMPOSABLE_PRODUCT_URL . 'assets/css/admin.css', WC_COMPOSABLE_PRODUCT_URL . 'assets/css/admin.css',
[], array(),
WC_COMPOSABLE_PRODUCT_VERSION WC_COMPOSABLE_PRODUCT_VERSION
); );
wp_enqueue_script( wp_enqueue_script(
'wc-composable-product-admin', 'wc-composable-product-admin',
WC_COMPOSABLE_PRODUCT_URL . 'assets/js/admin.js', WC_COMPOSABLE_PRODUCT_URL . 'assets/js/admin.js',
['jquery', 'wc-admin-product-meta-boxes'], array( 'jquery', 'wc-admin-product-meta-boxes' ),
WC_COMPOSABLE_PRODUCT_VERSION, WC_COMPOSABLE_PRODUCT_VERSION,
true true
); );
@@ -204,7 +217,7 @@ class Plugin {
* @param array $settings WooCommerce settings pages * @param array $settings WooCommerce settings pages
* @return array * @return array
*/ */
public function add_settings_page($settings) { public function add_settings_page( $settings ) {
// Include Settings.php here, when WC_Settings_Page is guaranteed to be loaded // Include Settings.php here, when WC_Settings_Page is guaranteed to be loaded
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Admin/Settings.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Admin/Settings.php';
$settings[] = new Admin\Settings(); $settings[] = new Admin\Settings();
@@ -227,7 +240,7 @@ class Plugin {
* @param array $context Template variables * @param array $context Template variables
* @return string * @return string
*/ */
public function render_template($template, $context = []) { public function render_template( $template, $context = array() ) {
return $this->twig->render($template, $context); return $this->twig->render( $template, $context );
} }
} }

View File

@@ -7,7 +7,7 @@
namespace Magdev\WcComposableProduct; namespace Magdev\WcComposableProduct;
defined('ABSPATH') || exit; defined( 'ABSPATH' ) || exit;
/** /**
* Product Selector Class * Product Selector Class
@@ -20,8 +20,8 @@ class ProductSelector {
* *
* @param ProductType $product Composable product * @param ProductType $product Composable product
*/ */
public static function render($product) { public static function render( $product ) {
if (!$product || $product->get_type() !== 'composable') { if ( ! $product || $product->get_type() !== 'composable' ) {
return; return;
} }
@@ -29,34 +29,34 @@ class ProductSelector {
$selection_limit = $product->get_selection_limit(); $selection_limit = $product->get_selection_limit();
$pricing_mode = $product->get_pricing_mode(); $pricing_mode = $product->get_pricing_mode();
$show_images = get_option('wc_composable_show_images', 'yes') === 'yes'; $show_images = get_option( 'wc_composable_show_images', 'yes' ) === 'yes';
$show_prices = get_option('wc_composable_show_prices', 'yes') === 'yes'; $show_prices = get_option( 'wc_composable_show_prices', 'yes' ) === 'yes';
$show_total = get_option('wc_composable_show_total', 'yes') === 'yes'; $show_total = get_option( 'wc_composable_show_total', 'yes' ) === 'yes';
// Get stock manager for stock information // Get stock manager for stock information
$stock_manager = new StockManager(); $stock_manager = new StockManager();
// Prepare product data for template // Prepare product data for template
$products_data = []; $products_data = array();
foreach ($available_products as $available_product) { foreach ( $available_products as $available_product ) {
$stock_info = $stock_manager->get_product_stock_info($available_product->get_id()); $stock_info = $stock_manager->get_product_stock_info( $available_product->get_id() );
$products_data[] = [ $products_data[] = array(
'id' => $available_product->get_id(), 'id' => $available_product->get_id(),
'name' => $available_product->get_name(), 'name' => $available_product->get_name(),
'price' => $available_product->get_price(), 'price' => $available_product->get_price(),
'price_html' => $available_product->get_price_html(), 'price_html' => $available_product->get_price_html(),
'image_url' => wp_get_attachment_image_url($available_product->get_image_id(), 'thumbnail'), 'image_url' => wp_get_attachment_image_url( $available_product->get_image_id(), 'thumbnail' ),
'permalink' => $available_product->get_permalink(), 'permalink' => $available_product->get_permalink(),
'stock_status' => $stock_info['stock_status'], 'stock_status' => $stock_info['stock_status'],
'in_stock' => $stock_info['in_stock'], 'in_stock' => $stock_info['in_stock'],
'stock_quantity' => $stock_info['stock_quantity'], 'stock_quantity' => $stock_info['stock_quantity'],
'managing_stock' => $stock_info['managing_stock'], 'managing_stock' => $stock_info['managing_stock'],
'backorders_allowed' => $stock_info['backorders_allowed'], 'backorders_allowed' => $stock_info['backorders_allowed'],
]; );
} }
$context = [ $context = array(
'product_id' => $product->get_id(), 'product_id' => $product->get_id(),
'products' => $products_data, 'products' => $products_data,
'selection_limit' => $selection_limit, 'selection_limit' => $selection_limit,
@@ -65,13 +65,14 @@ class ProductSelector {
'show_prices' => $show_prices, 'show_prices' => $show_prices,
'show_total' => $show_total, 'show_total' => $show_total,
'fixed_price' => $product->get_price(), 'fixed_price' => $product->get_price(),
'fixed_price_html' => wc_price($product->get_price()), 'fixed_price_html' => wc_price( $product->get_price() ),
'zero_price_html' => wc_price(0), 'zero_price_html' => wc_price( 0 ),
'currency_symbol' => get_woocommerce_currency_symbol(), 'currency_symbol' => get_woocommerce_currency_symbol(),
]; );
// Render template // Render template — Twig handles escaping via registered esc_html/esc_attr/esc_url functions.
$plugin = Plugin::instance(); $plugin = Plugin::instance();
echo $plugin->render_template('product-selector.twig', $context); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- output escaped by Twig template.
echo $plugin->render_template( 'product-selector.twig', $context );
} }
} }

View File

@@ -7,7 +7,7 @@
namespace Magdev\WcComposableProduct; namespace Magdev\WcComposableProduct;
defined('ABSPATH') || exit; defined( 'ABSPATH' ) || exit;
/** /**
* Composable Product Type Class * Composable Product Type Class
@@ -25,9 +25,9 @@ class ProductType extends \WC_Product {
* *
* @param mixed $product Product ID or object * @param mixed $product Product ID or object
*/ */
public function __construct($product = 0) { public function __construct( $product = 0 ) {
$this->supports[] = 'ajax_add_to_cart'; $this->supports[] = 'ajax_add_to_cart';
parent::__construct($product); parent::__construct( $product );
} }
/** /**
@@ -45,11 +45,11 @@ class ProductType extends \WC_Product {
* @return int * @return int
*/ */
public function get_selection_limit() { public function get_selection_limit() {
$limit = $this->get_meta('_composable_selection_limit', true); $limit = $this->get_meta( '_composable_selection_limit', true );
if (empty($limit)) { if ( empty( $limit ) ) {
$limit = get_option('wc_composable_default_limit', 5); $limit = get_option( 'wc_composable_default_limit', 5 );
} }
return absint($limit); return absint( $limit );
} }
/** /**
@@ -58,9 +58,9 @@ class ProductType extends \WC_Product {
* @return string 'fixed' or 'sum' * @return string 'fixed' or 'sum'
*/ */
public function get_pricing_mode() { public function get_pricing_mode() {
$mode = $this->get_meta('_composable_pricing_mode', true); $mode = $this->get_meta( '_composable_pricing_mode', true );
if (empty($mode)) { if ( empty( $mode ) ) {
$mode = get_option('wc_composable_default_pricing', 'sum'); $mode = get_option( 'wc_composable_default_pricing', 'sum' );
} }
return $mode; return $mode;
} }
@@ -71,12 +71,17 @@ class ProductType extends \WC_Product {
* @return array * @return array
*/ */
public function get_selection_criteria() { public function get_selection_criteria() {
return [ $type = $this->get_meta( '_composable_criteria_type', true );
'type' => $this->get_meta('_composable_criteria_type', true) ?: 'category', $categories = $this->get_meta( '_composable_categories', true );
'categories' => $this->get_meta('_composable_categories', true) ?: [], $tags = $this->get_meta( '_composable_tags', true );
'tags' => $this->get_meta('_composable_tags', true) ?: [], $skus = $this->get_meta( '_composable_skus', true );
'skus' => $this->get_meta('_composable_skus', true) ?: '',
]; return array(
'type' => $type ? $type : 'category',
'categories' => $categories ? $categories : array(),
'tags' => $tags ? $tags : array(),
'skus' => $skus ? $skus : '',
);
} }
/** /**
@@ -103,14 +108,14 @@ class ProductType extends \WC_Product {
* @return bool * @return bool
*/ */
public function should_include_unpublished() { public function should_include_unpublished() {
$per_product = $this->get_meta('_composable_include_unpublished', true); $per_product = $this->get_meta( '_composable_include_unpublished', true );
if ($per_product === 'yes') { if ( 'yes' === $per_product ) {
return true; return true;
} }
if ($per_product === 'no') { if ( 'no' === $per_product ) {
return false; return false;
} }
return get_option('wc_composable_include_unpublished', 'no') === 'yes'; return 'yes' === get_option( 'wc_composable_include_unpublished', 'no' );
} }
/** /**
@@ -121,84 +126,84 @@ class ProductType extends \WC_Product {
public function get_available_products() { public function get_available_products() {
$criteria = $this->get_selection_criteria(); $criteria = $this->get_selection_criteria();
$include_unpublished = $this->should_include_unpublished(); $include_unpublished = $this->should_include_unpublished();
$args = [ $args = array(
'post_type' => 'product', 'post_type' => 'product',
'posts_per_page' => -1, 'posts_per_page' => -1,
'post_status' => $include_unpublished ? ['publish', 'draft', 'private'] : 'publish', 'post_status' => $include_unpublished ? array( 'publish', 'draft', 'private' ) : 'publish',
'orderby' => 'title', 'orderby' => 'title',
'order' => 'ASC', 'order' => 'ASC',
]; );
// Exclude composable products using the product_type taxonomy // Exclude composable products using the product_type taxonomy
// (WooCommerce stores product types as taxonomy terms, NOT as postmeta) // (WooCommerce stores product types as taxonomy terms, NOT as postmeta)
$args['tax_query'] = [ $args['tax_query'] = array(
'relation' => 'AND', 'relation' => 'AND',
[ array(
'taxonomy' => 'product_type', 'taxonomy' => 'product_type',
'field' => 'slug', 'field' => 'slug',
'terms' => ['composable'], 'terms' => array( 'composable' ),
'operator' => 'NOT IN', 'operator' => 'NOT IN',
], ),
]; );
switch ($criteria['type']) { switch ( $criteria['type'] ) {
case 'category': case 'category':
if (!empty($criteria['categories'])) { if ( ! empty( $criteria['categories'] ) ) {
$args['tax_query'][] = [ $args['tax_query'][] = array(
'taxonomy' => 'product_cat', 'taxonomy' => 'product_cat',
'field' => 'term_id', 'field' => 'term_id',
'terms' => $criteria['categories'], 'terms' => $criteria['categories'],
'operator' => 'IN', 'operator' => 'IN',
]; );
} }
break; break;
case 'tag': case 'tag':
if (!empty($criteria['tags'])) { if ( ! empty( $criteria['tags'] ) ) {
$args['tax_query'][] = [ $args['tax_query'][] = array(
'taxonomy' => 'product_tag', 'taxonomy' => 'product_tag',
'field' => 'term_id', 'field' => 'term_id',
'terms' => $criteria['tags'], 'terms' => $criteria['tags'],
'operator' => 'IN', 'operator' => 'IN',
]; );
} }
break; break;
case 'sku': case 'sku':
if (!empty($criteria['skus'])) { if ( ! empty( $criteria['skus'] ) ) {
$skus = array_map('trim', explode(',', $criteria['skus'])); $skus = array_map( 'trim', explode( ',', $criteria['skus'] ) );
$args['meta_query'] = [ $args['meta_query'] = array(
[ array(
'key' => '_sku', 'key' => '_sku',
'value' => $skus, 'value' => $skus,
'compare' => 'IN', 'compare' => 'IN',
], ),
]; );
} }
break; break;
} }
$query = new \WP_Query($args); $query = new \WP_Query( $args );
$products = []; $products = array();
if ($query->have_posts()) { if ( $query->have_posts() ) {
foreach ($query->posts as $post) { foreach ( $query->posts as $post ) {
$product = wc_get_product($post->ID); $product = wc_get_product( $post->ID );
if (!$product) { if ( ! $product ) {
continue; continue;
} }
// Handle variable products by including their variations // Handle variable products by including their variations
if ($product->is_type('variable')) { if ( $product->is_type( 'variable' ) ) {
$variation_ids = $product->get_children(); $variation_ids = $product->get_children();
foreach ($variation_ids as $variation_id) { foreach ( $variation_ids as $variation_id ) {
$variation = wc_get_product($variation_id); $variation = wc_get_product( $variation_id );
if ($variation && ($include_unpublished || $variation->is_purchasable())) { if ( $variation && ( $include_unpublished || $variation->is_purchasable() ) ) {
$products[] = $variation; $products[] = $variation;
} }
} }
} elseif ($include_unpublished || $product->is_purchasable()) { } elseif ( $include_unpublished || $product->is_purchasable() ) {
$products[] = $product; $products[] = $product;
} }
} }
@@ -215,18 +220,18 @@ class ProductType extends \WC_Product {
* @param array $selected_products Array of product IDs * @param array $selected_products Array of product IDs
* @return float * @return float
*/ */
public function calculate_composed_price($selected_products) { public function calculate_composed_price( $selected_products ) {
$pricing_mode = $this->get_pricing_mode(); $pricing_mode = $this->get_pricing_mode();
if ($pricing_mode === 'fixed') { if ( 'fixed' === $pricing_mode ) {
return floatval($this->get_regular_price()); return floatval( $this->get_regular_price() );
} }
$total = 0; $total = 0;
foreach ($selected_products as $product_id) { foreach ( $selected_products as $product_id ) {
$product = wc_get_product($product_id); $product = wc_get_product( $product_id );
if ($product) { if ( $product ) {
$total += floatval($product->get_price()); $total += floatval( $product->get_price() );
} }
} }
@@ -243,7 +248,7 @@ class ProductType extends \WC_Product {
* @param array $cart_item_data Cart item data * @param array $cart_item_data Cart item data
* @return bool * @return bool
*/ */
public function add_to_cart_validation($product_id, $quantity, $variation_id = 0, $variations = [], $cart_item_data = []) { public function add_to_cart_validation( $product_id, $quantity, $variation_id = 0, $variations = array(), $cart_item_data = array() ) {
return true; return true;
} }
} }

View File

@@ -7,7 +7,7 @@
namespace Magdev\WcComposableProduct; namespace Magdev\WcComposableProduct;
defined('ABSPATH') || exit; defined( 'ABSPATH' ) || exit;
/** /**
* Stock Manager Class * Stock Manager Class
@@ -20,15 +20,15 @@ class StockManager {
*/ */
public function __construct() { public function __construct() {
// Hook into order completion to reduce stock // Hook into order completion to reduce stock
add_action('woocommerce_order_status_completed', [$this, 'reduce_stock_on_order_complete'], 10, 1); add_action( 'woocommerce_order_status_completed', array( $this, 'reduce_stock_on_order_complete' ), 10, 1 );
add_action('woocommerce_order_status_processing', [$this, 'reduce_stock_on_order_complete'], 10, 1); add_action( 'woocommerce_order_status_processing', array( $this, 'reduce_stock_on_order_complete' ), 10, 1 );
// Hook into order cancellation/refund to restore stock // Hook into order cancellation/refund to restore stock
add_action('woocommerce_order_status_cancelled', [$this, 'restore_stock_on_order_cancel'], 10, 1); add_action( 'woocommerce_order_status_cancelled', array( $this, 'restore_stock_on_order_cancel' ), 10, 1 );
add_action('woocommerce_order_status_refunded', [$this, 'restore_stock_on_order_cancel'], 10, 1); add_action( 'woocommerce_order_status_refunded', array( $this, 'restore_stock_on_order_cancel' ), 10, 1 );
// Prevent double stock reduction // Prevent double stock reduction
add_filter('woocommerce_can_reduce_order_stock', [$this, 'prevent_composable_stock_reduction'], 10, 2); add_filter( 'woocommerce_can_reduce_order_stock', array( $this, 'prevent_composable_stock_reduction' ), 10, 2 );
} }
/** /**
@@ -38,42 +38,42 @@ class StockManager {
* @param int $quantity Quantity of composable product being added * @param int $quantity Quantity of composable product being added
* @return bool|string True if in stock, error message otherwise * @return bool|string True if in stock, error message otherwise
*/ */
public function validate_stock_availability($selected_product_ids, $quantity = 1) { public function validate_stock_availability( $selected_product_ids, $quantity = 1 ) {
foreach ($selected_product_ids as $product_id) { foreach ( $selected_product_ids as $product_id ) {
$product = wc_get_product($product_id); $product = wc_get_product( $product_id );
if (!$product) { if ( ! $product ) {
continue; continue;
} }
// Skip stock check if stock management is disabled for this product // Skip stock check if stock management is disabled for this product
if (!$product->managing_stock()) { if ( ! $product->managing_stock() ) {
continue; continue;
} }
$stock_quantity = $product->get_stock_quantity(); $stock_quantity = $product->get_stock_quantity();
// Check if product is in stock // Check if product is in stock
if (!$product->is_in_stock()) { if ( ! $product->is_in_stock() ) {
return sprintf( return sprintf(
/* translators: %s: product name */ /* translators: %s: product name */
__('"%s" is out of stock and cannot be selected.', 'wc-composable-product'), __( '"%s" is out of stock and cannot be selected.', 'wc-composable-product' ),
$product->get_name() $product->get_name()
); );
} }
// Check if enough stock is available // Check if enough stock is available
if ($stock_quantity !== null && $stock_quantity < $quantity) { if ( null !== $stock_quantity && $stock_quantity < $quantity ) {
return sprintf( return sprintf(
/* translators: 1: product name, 2: stock quantity */ /* translators: 1: product name, 2: stock quantity */
__('Only %2$d of "%1$s" are available in stock.', 'wc-composable-product'), __( 'Only %2$d of "%1$s" are available in stock.', 'wc-composable-product' ),
$product->get_name(), $product->get_name(),
$stock_quantity $stock_quantity
); );
} }
// Check for backorders // Check for backorders
if ($product->backorders_allowed()) { if ( $product->backorders_allowed() ) {
continue; continue;
} }
} }
@@ -88,29 +88,29 @@ class StockManager {
* @param int $required_quantity Required quantity * @param int $required_quantity Required quantity
* @return array Stock information [in_stock, stock_quantity, backorders_allowed] * @return array Stock information [in_stock, stock_quantity, backorders_allowed]
*/ */
public function get_product_stock_info($product_id, $required_quantity = 1) { public function get_product_stock_info( $product_id, $required_quantity = 1 ) {
$product = wc_get_product($product_id); $product = wc_get_product( $product_id );
if (!$product) { if ( ! $product ) {
return [ return array(
'in_stock' => false, 'in_stock' => false,
'stock_quantity' => 0, 'stock_quantity' => 0,
'backorders_allowed' => false, 'backorders_allowed' => false,
'stock_status' => 'outofstock', 'stock_status' => 'outofstock',
]; );
} }
$stock_quantity = $product->get_stock_quantity(); $stock_quantity = $product->get_stock_quantity();
$managing_stock = $product->managing_stock(); $managing_stock = $product->managing_stock();
return [ return array(
'in_stock' => $product->is_in_stock(), 'in_stock' => $product->is_in_stock(),
'stock_quantity' => $stock_quantity, 'stock_quantity' => $stock_quantity,
'backorders_allowed' => $product->backorders_allowed(), 'backorders_allowed' => $product->backorders_allowed(),
'stock_status' => $product->get_stock_status(), 'stock_status' => $product->get_stock_status(),
'managing_stock' => $managing_stock, 'managing_stock' => $managing_stock,
'has_enough_stock' => !$managing_stock || $stock_quantity === null || $stock_quantity >= $required_quantity, 'has_enough_stock' => ! $managing_stock || null === $stock_quantity || $stock_quantity >= $required_quantity,
]; );
} }
/** /**
@@ -118,54 +118,54 @@ class StockManager {
* *
* @param int $order_id Order ID * @param int $order_id Order ID
*/ */
public function reduce_stock_on_order_complete($order_id) { public function reduce_stock_on_order_complete( $order_id ) {
$order = wc_get_order($order_id); $order = wc_get_order( $order_id );
if (!$order) { if ( ! $order ) {
return; return;
} }
// Check if stock has already been reduced // Check if stock has already been reduced
if ($order->get_meta('_composable_stock_reduced', true)) { if ( $order->get_meta( '_composable_stock_reduced', true ) ) {
return; return;
} }
foreach ($order->get_items() as $item) { foreach ( $order->get_items() as $item ) {
$product = $item->get_product(); $product = $item->get_product();
if (!$product || $product->get_type() !== 'composable') { if ( ! $product || $product->get_type() !== 'composable' ) {
continue; continue;
} }
// Get selected products from order item meta // Get selected products from order item meta
$selected_products = $item->get_meta('_composable_products', true); $selected_products = $item->get_meta( '_composable_products', true );
if (empty($selected_products) || !is_array($selected_products)) { if ( empty( $selected_products ) || ! is_array( $selected_products ) ) {
continue; continue;
} }
$quantity = $item->get_quantity(); $quantity = $item->get_quantity();
// Reduce stock for each selected product // Reduce stock for each selected product
foreach ($selected_products as $product_id) { foreach ( $selected_products as $product_id ) {
$selected_product = wc_get_product($product_id); $selected_product = wc_get_product( $product_id );
if (!$selected_product || !$selected_product->managing_stock()) { if ( ! $selected_product || ! $selected_product->managing_stock() ) {
continue; continue;
} }
$stock_quantity = $selected_product->get_stock_quantity(); $stock_quantity = $selected_product->get_stock_quantity();
if ($stock_quantity !== null) { if ( null !== $stock_quantity ) {
$new_stock = $stock_quantity - $quantity; $new_stock = $stock_quantity - $quantity;
$selected_product->set_stock_quantity($new_stock); $selected_product->set_stock_quantity( $new_stock );
$selected_product->save(); $selected_product->save();
// Add order note // Add order note
$order->add_order_note( $order->add_order_note(
sprintf( sprintf(
/* translators: 1: product name, 2: quantity, 3: remaining stock */ /* translators: 1: product name, 2: quantity, 3: remaining stock */
__('Stock reduced for "%1$s": -%2$d (remaining: %3$d)', 'wc-composable-product'), __( 'Stock reduced for "%1$s": -%2$d (remaining: %3$d)', 'wc-composable-product' ),
$selected_product->get_name(), $selected_product->get_name(),
$quantity, $quantity,
$new_stock $new_stock
@@ -176,7 +176,7 @@ class StockManager {
} }
// Mark stock as reduced // Mark stock as reduced
$order->update_meta_data('_composable_stock_reduced', true); $order->update_meta_data( '_composable_stock_reduced', true );
$order->save(); $order->save();
} }
@@ -185,54 +185,54 @@ class StockManager {
* *
* @param int $order_id Order ID * @param int $order_id Order ID
*/ */
public function restore_stock_on_order_cancel($order_id) { public function restore_stock_on_order_cancel( $order_id ) {
$order = wc_get_order($order_id); $order = wc_get_order( $order_id );
if (!$order) { if ( ! $order ) {
return; return;
} }
// Check if stock was reduced // Check if stock was reduced
if (!$order->get_meta('_composable_stock_reduced', true)) { if ( ! $order->get_meta( '_composable_stock_reduced', true ) ) {
return; return;
} }
foreach ($order->get_items() as $item) { foreach ( $order->get_items() as $item ) {
$product = $item->get_product(); $product = $item->get_product();
if (!$product || $product->get_type() !== 'composable') { if ( ! $product || $product->get_type() !== 'composable' ) {
continue; continue;
} }
// Get selected products from order item meta // Get selected products from order item meta
$selected_products = $item->get_meta('_composable_products', true); $selected_products = $item->get_meta( '_composable_products', true );
if (empty($selected_products) || !is_array($selected_products)) { if ( empty( $selected_products ) || ! is_array( $selected_products ) ) {
continue; continue;
} }
$quantity = $item->get_quantity(); $quantity = $item->get_quantity();
// Restore stock for each selected product // Restore stock for each selected product
foreach ($selected_products as $product_id) { foreach ( $selected_products as $product_id ) {
$selected_product = wc_get_product($product_id); $selected_product = wc_get_product( $product_id );
if (!$selected_product || !$selected_product->managing_stock()) { if ( ! $selected_product || ! $selected_product->managing_stock() ) {
continue; continue;
} }
$stock_quantity = $selected_product->get_stock_quantity(); $stock_quantity = $selected_product->get_stock_quantity();
if ($stock_quantity !== null) { if ( null !== $stock_quantity ) {
$new_stock = $stock_quantity + $quantity; $new_stock = $stock_quantity + $quantity;
$selected_product->set_stock_quantity($new_stock); $selected_product->set_stock_quantity( $new_stock );
$selected_product->save(); $selected_product->save();
// Add order note // Add order note
$order->add_order_note( $order->add_order_note(
sprintf( sprintf(
/* translators: 1: product name, 2: quantity, 3: new stock */ /* translators: 1: product name, 2: quantity, 3: new stock */
__('Stock restored for "%1$s": +%2$d (total: %3$d)', 'wc-composable-product'), __( 'Stock restored for "%1$s": +%2$d (total: %3$d)', 'wc-composable-product' ),
$selected_product->get_name(), $selected_product->get_name(),
$quantity, $quantity,
$new_stock $new_stock
@@ -243,7 +243,7 @@ class StockManager {
} }
// Mark stock as restored // Mark stock as restored
$order->update_meta_data('_composable_stock_reduced', false); $order->update_meta_data( '_composable_stock_reduced', false );
$order->save(); $order->save();
} }
@@ -255,11 +255,11 @@ class StockManager {
* @param \WC_Order $order Order object * @param \WC_Order $order Order object
* @return bool * @return bool
*/ */
public function prevent_composable_stock_reduction($reduce_stock, $order) { public function prevent_composable_stock_reduction( $reduce_stock, $order ) {
foreach ($order->get_items() as $item) { foreach ( $order->get_items() as $item ) {
$product = $item->get_product(); $product = $item->get_product();
if ($product && $product->get_type() === 'composable') { if ( $product && $product->get_type() === 'composable' ) {
// We'll handle stock reduction manually // We'll handle stock reduction manually
return false; return false;
} }
@@ -275,9 +275,9 @@ class StockManager {
* @param string $cart_item_key Cart item key * @param string $cart_item_key Cart item key
* @param array $values Cart item values * @param array $values Cart item values
*/ */
public function store_selected_products_in_order($item, $cart_item_key, $values) { public function store_selected_products_in_order( $item, $cart_item_key, $values ) {
if (isset($values['composable_products']) && !empty($values['composable_products'])) { if ( isset( $values['composable_products'] ) && ! empty( $values['composable_products'] ) ) {
$item->add_meta_data('_composable_products', $values['composable_products'], true); $item->add_meta_data( '_composable_products', $values['composable_products'], true );
} }
} }
} }

34
phpcs.xml.dist Normal file
View File

@@ -0,0 +1,34 @@
<?xml version="1.0"?>
<ruleset name="WC Composable Product">
<description>PHPCS rules for WooCommerce Composable Products plugin</description>
<!-- Scan plugin source -->
<file>includes</file>
<file>wc-composable-product.php</file>
<!-- Show progress and use colors -->
<arg value="ps"/>
<arg name="colors"/>
<!-- Use WordPress Extra standard (Core + Extra, without Docs) -->
<rule ref="WordPress-Extra">
<!-- Allow PSR-4 PascalCase file naming -->
<exclude name="WordPress.Files.FileName"/>
</rule>
<!-- Check PHP 8.3+ compatibility -->
<rule ref="PHPCompatibilityWP"/>
<config name="testVersion" value="8.3-"/>
<!-- WordPress minimum version -->
<config name="minimum_wp_version" value="6.0"/>
<!-- Enforce text domain -->
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="wc-composable-product"/>
</property>
</properties>
</rule>
</ruleset>

View File

@@ -1,14 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit <phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
bootstrap="tests/bootstrap.php" bootstrap="tests/bootstrap.php"
colors="true" colors="true"
verbose="true"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="false"
failOnWarning="true" failOnWarning="true"
cacheDirectory=".phpunit.cache"
> >
<testsuites> <testsuites>
<testsuite name="Unit"> <testsuite name="Unit">
@@ -16,9 +13,9 @@
</testsuite> </testsuite>
</testsuites> </testsuites>
<coverage processUncoveredFiles="true"> <source>
<include> <include>
<directory suffix=".php">includes</directory> <directory suffix=".php">includes</directory>
</include> </include>
</coverage> </source>
</phpunit> </phpunit>

View File

@@ -17,17 +17,17 @@
* WC tested up to: 10.0 * WC tested up to: 10.0
*/ */
defined('ABSPATH') || exit; defined( 'ABSPATH' ) || exit;
// Define plugin constants // Define plugin constants
define('WC_COMPOSABLE_PRODUCT_VERSION', '1.3.1'); define( 'WC_COMPOSABLE_PRODUCT_VERSION', '1.3.1' );
define('WC_COMPOSABLE_PRODUCT_FILE', __FILE__); define( 'WC_COMPOSABLE_PRODUCT_FILE', __FILE__ );
define('WC_COMPOSABLE_PRODUCT_PATH', plugin_dir_path(__FILE__)); define( 'WC_COMPOSABLE_PRODUCT_PATH', plugin_dir_path( __FILE__ ) );
define('WC_COMPOSABLE_PRODUCT_URL', plugin_dir_url(__FILE__)); define( 'WC_COMPOSABLE_PRODUCT_URL', plugin_dir_url( __FILE__ ) );
define('WC_COMPOSABLE_PRODUCT_BASENAME', plugin_basename(__FILE__)); define( 'WC_COMPOSABLE_PRODUCT_BASENAME', plugin_basename( __FILE__ ) );
// Load Composer autoloader // Load Composer autoloader
if (file_exists(WC_COMPOSABLE_PRODUCT_PATH . 'vendor/autoload.php')) { if ( file_exists( WC_COMPOSABLE_PRODUCT_PATH . 'vendor/autoload.php' ) ) {
require_once WC_COMPOSABLE_PRODUCT_PATH . 'vendor/autoload.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'vendor/autoload.php';
} }
@@ -35,14 +35,17 @@ if (file_exists(WC_COMPOSABLE_PRODUCT_PATH . 'vendor/autoload.php')) {
* Check if WooCommerce is active * Check if WooCommerce is active
*/ */
function wc_composable_product_check_woocommerce() { function wc_composable_product_check_woocommerce() {
if (!class_exists('WooCommerce')) { if ( ! class_exists( 'WooCommerce' ) ) {
add_action('admin_notices', function() { add_action(
'admin_notices',
function () {
?> ?>
<div class="notice notice-error"> <div class="notice notice-error">
<p><?php esc_html_e('WooCommerce Composable Products requires WooCommerce to be installed and active.', 'wc-composable-product'); ?></p> <p><?php esc_html_e( 'WooCommerce Composable Products requires WooCommerce to be installed and active.', 'wc-composable-product' ); ?></p>
</div> </div>
<?php <?php
}); }
);
return false; return false;
} }
return true; return true;
@@ -52,39 +55,42 @@ function wc_composable_product_check_woocommerce() {
* Initialize the plugin * Initialize the plugin
*/ */
function wc_composable_product_init() { function wc_composable_product_init() {
if (!wc_composable_product_check_woocommerce()) { if ( ! wc_composable_product_check_woocommerce() ) {
return; return;
} }
// Load text domain // Load text domain
load_plugin_textdomain('wc-composable-product', false, dirname(WC_COMPOSABLE_PRODUCT_BASENAME) . '/languages'); load_plugin_textdomain( 'wc-composable-product', false, dirname( WC_COMPOSABLE_PRODUCT_BASENAME ) . '/languages' );
// Initialize main plugin class // Initialize main plugin class
Magdev\WcComposableProduct\Plugin::instance(); Magdev\WcComposableProduct\Plugin::instance();
} }
// Use woocommerce_init to ensure all WooCommerce classes including settings are loaded // Use woocommerce_init to ensure all WooCommerce classes including settings are loaded
add_action('woocommerce_init', 'wc_composable_product_init'); add_action( 'woocommerce_init', 'wc_composable_product_init' );
/** /**
* Declare HPOS compatibility * Declare HPOS compatibility
*/ */
add_action('before_woocommerce_init', function() { add_action(
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) { 'before_woocommerce_init',
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true); function () {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
} }
}); }
);
/** /**
* Activation hook * Activation hook
*/ */
function wc_composable_product_activate() { function wc_composable_product_activate() {
if (!class_exists('WooCommerce')) { if ( ! class_exists( 'WooCommerce' ) ) {
deactivate_plugins(WC_COMPOSABLE_PRODUCT_BASENAME); deactivate_plugins( WC_COMPOSABLE_PRODUCT_BASENAME );
wp_die( wp_die(
esc_html__('This plugin requires WooCommerce to be installed and active.', 'wc-composable-product'), esc_html__( 'This plugin requires WooCommerce to be installed and active.', 'wc-composable-product' ),
esc_html__('Plugin Activation Error', 'wc-composable-product'), esc_html__( 'Plugin Activation Error', 'wc-composable-product' ),
array('back_link' => true) array( 'back_link' => true )
); );
} }
} }
register_activation_hook(__FILE__, 'wc_composable_product_activate'); register_activation_hook( __FILE__, 'wc_composable_product_activate' );