5 Commits

Author SHA1 Message Date
dea1b055b2 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>
2026-03-01 13:25:02 +01:00
a7d6a57f01 Add PHPUnit test suite, PSR-4 refactor, lint+test CI jobs (v1.3.1)
Some checks failed
Create Release Package / PHP Lint (push) Successful in 47s
Create Release Package / test (push) Failing after 53s
Create Release Package / build-release (push) Has been skipped
- 57 unit tests covering ProductType, StockManager, CartHandler, Plugin,
  Admin/ProductData, Admin/Settings using Brain Monkey + Mockery
- WooCommerce class stubs for testing without WP installation
- PHP lint and test jobs in release workflow (test gate blocks release)
- PSR-4 namespace change: WC_Composable_Product -> Magdev\WcComposableProduct
- PascalCase filenames for all classes under includes/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:08:22 +01:00
ea2261d8d7 Refactor to PSR-4: rename files and switch namespace
- Rename files to PascalCase: Product_Type → ProductType, Cart_Handler →
  CartHandler, Product_Selector → ProductSelector, Stock_Manager →
  StockManager, Admin/Product_Data → Admin/ProductData
- Switch namespace from WC_Composable_Product to Magdev\WcComposableProduct
- Update all cross-references in PHP, CSS, JS, translations, and docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:35:02 +01:00
dd5965ae4c Add option to include non-public products in selections (v1.3.0)
All checks were successful
Create Release Package / build-release (push) Successful in 1m6s
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. Includes global setting and per-product
override with translations in all 6 locales.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:23:01 +01:00
9bc7a62f20 commit the composer lockfile 2026-03-01 12:17:04 +01:00
48 changed files with 6137 additions and 1567 deletions

View File

@@ -6,7 +6,75 @@ on:
- 'v*' - 'v*'
jobs: jobs:
lint:
name: PHP Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, zip
tools: composer:v2
- name: PHP Syntax Check
run: |
find includes -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:
name: PHP Unit
runs-on: ubuntu-latest
needs: lint
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 PHPUnit tests
run: vendor/bin/phpunit --testdox
build-release: build-release:
needs: [test, phpcs]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code

5
.gitignore vendored
View File

@@ -18,6 +18,11 @@ logs/
Thumbs.db Thumbs.db
.directory .directory
# PHPUnit local overrides
phpunit.xml
# Binary files # Binary files
languages/*.mo languages/*.mo
.phpunit.result.cache
.phpunit.cache/

View File

@@ -5,6 +5,38 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.1] - 2026-03-01
### Added
- **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
- **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
- **Test job** in release workflow — tests must pass before release package is built
- Testing and linting sections in README and CLAUDE.md
### Changed
- **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
- **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
### Added
- **Include Non-Public Products**: New option to include draft and private products in composable product selections
- Global setting under WooCommerce > Settings > Composable Products
- Per-product override in the Composable Options tab (Use global default / Yes / No)
- Useful when products should only be sold as part of a composition, not individually
- Translations for the new setting in all 6 locales (de_DE, de_CH, fr_CH, it_CH + informal variants)
## [1.2.1] - 2026-03-01 ## [1.2.1] - 2026-03-01
### Changed ### Changed

View File

@@ -18,6 +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 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
@@ -36,19 +38,26 @@ wc-composable-product/
├── cache/ # Twig template cache (writable, gitignored) ├── cache/ # Twig template cache (writable, gitignored)
├── includes/ ├── includes/
│ ├── Admin/ │ ├── Admin/
│ │ ├── Product_Data.php # Product data tab & meta boxes │ │ ├── ProductData.php # Product data tab & meta boxes
│ │ └── Settings.php # WooCommerce settings integration │ │ └── Settings.php # WooCommerce settings integration
│ ├── Cart_Handler.php # Add-to-cart & cart display logic (with stock validation) │ ├── CartHandler.php # Add-to-cart & cart display logic (with stock validation)
│ ├── Plugin.php # Main plugin class (Singleton) │ ├── Plugin.php # Main plugin class (Singleton)
│ ├── Product_Selector.php # Frontend product selector renderer (with stock info) │ ├── ProductSelector.php # Frontend product selector renderer (with stock info)
│ ├── Product_Type.php # Custom WC_Product extension │ ├── ProductType.php # Custom WC_Product extension
│ └── Stock_Manager.php # Stock management & inventory tracking │ └── StockManager.php # Stock management & inventory tracking
├── languages/ # Translation files (.pot, .po, .mo) ├── languages/ # Translation files (.pot, .po, .mo)
├── releases/ # Release packages (gitignored) ├── releases/ # Release packages (gitignored)
├── tests/
│ ├── bootstrap.php # Test environment setup (constants, stubs)
│ ├── TestCase.php # Base test case with Brain Monkey
│ ├── stubs/ # Minimal WooCommerce class stubs
│ └── Unit/ # PHPUnit unit tests
├── templates/ ├── templates/
│ └── 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
└── wc-composable-product.php # Main plugin file └── wc-composable-product.php # Main plugin file
``` ```
@@ -61,29 +70,29 @@ wc-composable-product/
- Registers hooks, manages asset enqueuing, provides template rendering API - Registers hooks, manages asset enqueuing, provides template rendering API
- Settings.php is lazy-loaded via `woocommerce_get_settings_pages` filter (not in `includes()`) to avoid "Class WC_Settings_Page not found" errors - Settings.php is lazy-loaded via `woocommerce_get_settings_pages` filter (not in `includes()`) to avoid "Class WC_Settings_Page not found" errors
2. **Product_Type.php** — Custom WooCommerce product type (`composable`) 2. **ProductType.php** — Custom WooCommerce product type (`composable`)
- Extends `WC_Product` - Extends `WC_Product`
- Queries available products via `get_available_products()` using `WP_Query` - Queries available products via `get_available_products()` using `WP_Query`
- **Critical**: Uses `tax_query` with `product_type` taxonomy to exclude composable products (NOT `meta_query` — WooCommerce stores product types as taxonomy terms) - **Critical**: Uses `tax_query` with `product_type` taxonomy to exclude composable products (NOT `meta_query` — WooCommerce stores product types as taxonomy terms)
- Handles variable products by expanding them into individual variations via `get_children()` - Handles variable products by expanding them into individual variations via `get_children()`
- Products are filtered by `is_purchasable()` only (not `is_in_stock()` — stock is shown visually and validated at add-to-cart) - Products are filtered by `is_purchasable()` only (not `is_in_stock()` — stock is shown visually and validated at add-to-cart)
3. **Cart_Handler.php** — Cart integration 3. **CartHandler.php** — Cart integration
- Validates selections, stores selected products in cart meta, calculates pricing - Validates selections, stores selected products in cart meta, calculates pricing
- Uses `woocommerce_is_purchasable` filter to hide default add-to-cart button for composable products - Uses `woocommerce_is_purchasable` filter to hide default add-to-cart button for composable products
- Price recalculation uses a static `$already_calculated` flag per request (no persistent session flags — `set_price()` is in-memory only) - Price recalculation uses a static `$already_calculated` flag per request (no persistent session flags — `set_price()` is in-memory only)
4. **Product_Selector.php** — Frontend renderer 4. **ProductSelector.php** — Frontend renderer
- Renders Twig template with product data, stock info, and pre-formatted price HTML via `wc_price()` - Renders Twig template with product data, stock info, and pre-formatted price HTML via `wc_price()`
5. **Admin/Product_Data.php** — Product edit interface 5. **Admin/ProductData.php** — Product edit interface
- Adds "Composable Options" tab with category/tag/SKU selection fields - Adds "Composable Options" tab with category/tag/SKU selection fields
- Saved meta: `_composable_selection_limit`, `_composable_pricing_mode`, `_composable_criteria_type`, `_composable_categories`, `_composable_tags`, `_composable_skus` - Saved meta: `_composable_selection_limit`, `_composable_pricing_mode`, `_composable_criteria_type`, `_composable_categories`, `_composable_tags`, `_composable_skus`
6. **Admin/Settings.php** — Global settings (extends `WC_Settings_Page`) 6. **Admin/Settings.php** — Global settings (extends `WC_Settings_Page`)
- Default selection limit, pricing mode, display preferences - Default selection limit, pricing mode, display preferences
7. **Stock_Manager.php** — Inventory management 7. **StockManager.php** — Inventory management
- Stock validation, automatic deduction on order completion, restoration on cancellation - Stock validation, automatic deduction on order completion, restoration on cancellation
- Prevents WooCommerce double-deduction via `woocommerce_can_reduce_order_stock` - Prevents WooCommerce double-deduction via `woocommerce_can_reduce_order_stock`
@@ -91,11 +100,11 @@ wc-composable-product/
**Product Creation:** Admin selects "Composable product" type → configures criteria/limits/pricing → metadata saved as `_composable_*` fields **Product Creation:** Admin selects "Composable product" type → configures criteria/limits/pricing → metadata saved as `_composable_*` fields
**Frontend Display:** `Cart_Handler::render_product_selector()``Product_Type::get_available_products()` queries products via taxonomy/SKU → `Product_Selector::render()` passes data to Twig template → JavaScript handles selection UI **Frontend Display:** `CartHandler::render_product_selector()``ProductType::get_available_products()` queries products via taxonomy/SKU → `ProductSelector::render()` passes data to Twig template → JavaScript handles selection UI
**Add to Cart:** Customer selects products → JS validates limit → AJAX request with `composable_products[]` → server-side validation (selection + stock) → selections stored in cart item data → price calculated per pricing mode **Add to Cart:** Customer selects products → JS validates limit → AJAX request with `composable_products[]` → server-side validation (selection + stock) → selections stored in cart item data → price calculated per pricing mode
**Order Processing:** Order completed → `Stock_Manager` deducts inventory → order notes added for audit → on cancellation/refund: stock restored **Order Processing:** Order completed → `StockManager` deducts inventory → order notes added for audit → on cancellation/refund: stock restored
### Key Hooks ### Key Hooks
@@ -133,11 +142,21 @@ 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 & Linting
Run unit tests: `vendor/bin/phpunit --testdox`
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
### Automated (Gitea CI/CD) ### Automated (Gitea CI/CD)
Push an annotated tag (`v*`) to trigger the workflow. It installs PHP 8.3, production Composer deps, compiles translations, verifies version matches tag, creates ZIP with checksums, and publishes a Gitea release. Push an annotated tag (`v*`) to trigger the workflow. It first runs the PHPUnit test suite, then installs PHP 8.3, production Composer deps, compiles translations, verifies version matches tag, creates ZIP with checksums, and publishes a Gitea release. Tests must pass before the release package is built.
### Manual ### Manual

View File

@@ -12,7 +12,8 @@ Create composable products where customers can select a limited number of items
- **Pricing Options**: Fixed price or sum of selected products with full locale-aware formatting - **Pricing Options**: Fixed price or sum of selected products with full locale-aware formatting
- **Multi-language Support**: Fully translated in 6 locales (de_DE, de_CH, fr_CH, it_CH + informal variants) - **Multi-language Support**: Fully translated in 6 locales (de_DE, de_CH, fr_CH, it_CH + informal variants)
- **Modern UI**: Clean interface built with Twig templates and vanilla JavaScript - **Modern UI**: Clean interface built with Twig templates and vanilla JavaScript
- **CI/CD**: Automated release workflow for Gitea - **Tested**: 57 unit tests with PHPUnit, Brain Monkey, and Mockery
- **CI/CD**: Automated release workflow with test gate for Gitea
## Requirements ## Requirements
@@ -118,6 +119,16 @@ This project was created with AI assistance (Claude.AI) and follows WordPress an
composer install composer install
``` ```
### Running Tests
The plugin includes a PHPUnit test suite with Brain Monkey for WordPress function mocking:
```bash
vendor/bin/phpunit --testdox
```
Tests run without a WordPress installation. WooCommerce classes are provided as minimal stubs in `tests/stubs/`.
### Translation ### Translation
Generate POT file: Generate POT file:

View File

@@ -1,7 +1,7 @@
/** /**
* Admin Styles for Composable Products * Admin Styles for Composable Products
* *
* @package WC_Composable_Product * @package Magdev\WcComposableProduct
*/ */
/* Hide composable panel by default */ /* Hide composable panel by default */

View File

@@ -1,7 +1,7 @@
/** /**
* Frontend Styles for Composable Products * Frontend Styles for Composable Products
* *
* @package WC_Composable_Product * @package Magdev\WcComposableProduct
*/ */
.wc-composable-product-selector { .wc-composable-product-selector {

View File

@@ -1,7 +1,7 @@
/** /**
* Admin JavaScript for Composable Products * Admin JavaScript for Composable Products
* *
* @package WC_Composable_Product * @package Magdev\WcComposableProduct
*/ */
(function($) { (function($) {

View File

@@ -1,7 +1,7 @@
/** /**
* Frontend JavaScript for Composable Products * Frontend JavaScript for Composable Products
* *
* @package WC_Composable_Product * @package Magdev\WcComposableProduct
*/ */
(function($) { (function($) {

View File

@@ -15,11 +15,31 @@
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"WC_Composable_Product\\": "includes/" "Magdev\\WcComposableProduct\\": "includes/"
}
},
"require-dev": {
"brain/monkey": "^2.7",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"mockery/mockery": "^1.6",
"phpcompatibility/phpcompatibility-wp": "*",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.7",
"wp-coding-standards/wpcs": "^3.0"
},
"autoload-dev": {
"psr-4": {
"Magdev\\WcComposableProduct\\Tests\\": "tests/"
} }
}, },
"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"
}
} }
} }

2889
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

@@ -1,200 +0,0 @@
<?php
/**
* Product Data Tab
*
* @package WC_Composable_Product
*/
namespace WC_Composable_Product\Admin;
defined('ABSPATH') || exit;
/**
* Product Data Tab Class
*/
class Product_Data {
/**
* Constructor
*/
public function __construct() {
add_filter('woocommerce_product_data_tabs', [$this, 'add_product_data_tab']);
add_action('woocommerce_product_data_panels', [$this, 'add_product_data_panel']);
add_action('woocommerce_process_product_meta_composable', [$this, 'save_product_data']);
add_action('woocommerce_product_options_general_product_data', [$this, 'add_general_fields']);
}
/**
* Add composable products tab
*
* @param array $tabs Product data tabs
* @return array
*/
public function add_product_data_tab($tabs) {
$tabs['composable'] = [
'label' => __('Composable Options', 'wc-composable-product'),
'target' => 'composable_product_data',
'class' => ['show_if_composable'],
'priority' => 21,
];
return $tabs;
}
/**
* Add fields to general tab
*/
public function add_general_fields() {
global $product_object;
if ($product_object && $product_object->get_type() === 'composable') {
echo '<div class="options_group show_if_composable">';
woocommerce_wp_text_input([
'id' => '_composable_selection_limit',
'label' => __('Selection Limit', 'wc-composable-product'),
'description' => __('Maximum number of items customers can select. Leave empty to use global default.', 'wc-composable-product'),
'desc_tip' => true,
'type' => 'number',
'custom_attributes' => [
'min' => '1',
'step' => '1',
],
]);
woocommerce_wp_select([
'id' => '_composable_pricing_mode',
'label' => __('Pricing Mode', 'wc-composable-product'),
'description' => __('How to calculate the price.', 'wc-composable-product'),
'desc_tip' => true,
'options' => [
'' => __('Use global default', 'wc-composable-product'),
'sum' => __('Sum of selected products', 'wc-composable-product'),
'fixed' => __('Fixed price', 'wc-composable-product'),
],
]);
woocommerce_wp_text_input([
'id' => '_regular_price',
'label' => __('Fixed Price', 'wc-composable-product') . ' (' . get_woocommerce_currency_symbol() . ')',
'description' => __('Enter the fixed price for this composable product.', 'wc-composable-product'),
'desc_tip' => true,
'type' => 'text',
'data_type' => 'price',
'wrapper_class' => 'composable_fixed_price_field',
]);
echo '</div>';
}
}
/**
* Add product data panel
*/
public function add_product_data_panel() {
global $post;
?>
<div id="composable_product_data" class="panel woocommerce_options_panel hidden">
<div class="options_group">
<?php
woocommerce_wp_select([
'id' => '_composable_criteria_type',
'label' => __('Selection Criteria', 'wc-composable-product'),
'description' => __('How to select available products.', 'wc-composable-product'),
'desc_tip' => true,
'options' => [
'category' => __('By Category', 'wc-composable-product'),
'tag' => __('By Tag', 'wc-composable-product'),
'sku' => __('By SKU', 'wc-composable-product'),
],
'value' => get_post_meta($post->ID, '_composable_criteria_type', true) ?: 'category',
]);
?>
</div>
<div class="options_group composable_criteria_group" id="composable_criteria_category">
<p class="form-field">
<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%;">
<?php
$selected_categories = get_post_meta($post->ID, '_composable_categories', true) ?: [];
$categories = get_terms(['taxonomy' => 'product_cat', 'hide_empty' => false]);
foreach ($categories as $category) {
printf(
'<option value="%s" %s>%s</option>',
esc_attr($category->term_id),
selected(in_array($category->term_id, (array) $selected_categories), true, false),
esc_html($category->name)
);
}
?>
</select>
<span class="description"><?php esc_html_e('Select product categories to include.', 'wc-composable-product'); ?></span>
</p>
</div>
<div class="options_group composable_criteria_group" id="composable_criteria_tag" style="display: none;">
<p class="form-field">
<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%;">
<?php
$selected_tags = get_post_meta($post->ID, '_composable_tags', true) ?: [];
$tags = get_terms(['taxonomy' => 'product_tag', 'hide_empty' => false]);
foreach ($tags as $tag) {
printf(
'<option value="%s" %s>%s</option>',
esc_attr($tag->term_id),
selected(in_array($tag->term_id, (array) $selected_tags), true, false),
esc_html($tag->name)
);
}
?>
</select>
<span class="description"><?php esc_html_e('Select product tags to include.', 'wc-composable-product'); ?></span>
</p>
</div>
<div class="options_group composable_criteria_group" id="composable_criteria_sku" style="display: none;">
<?php
woocommerce_wp_textarea_input([
'id' => '_composable_skus',
'label' => __('Product SKUs', 'wc-composable-product'),
'description' => __('Enter product SKUs separated by commas.', 'wc-composable-product'),
'desc_tip' => true,
'placeholder' => __('SKU-1, SKU-2, SKU-3', 'wc-composable-product'),
]);
?>
</div>
</div>
<?php
}
/**
* Save product data
*
* @param int $post_id Post ID
*/
public function save_product_data($post_id) {
// Save selection limit
$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
$pricing_mode = isset($_POST['_composable_pricing_mode']) ? sanitize_text_field($_POST['_composable_pricing_mode']) : '';
update_post_meta($post_id, '_composable_pricing_mode', $pricing_mode);
// Save criteria type
$criteria_type = isset($_POST['_composable_criteria_type']) ? sanitize_text_field($_POST['_composable_criteria_type']) : 'category';
update_post_meta($post_id, '_composable_criteria_type', $criteria_type);
// Save categories
$categories = isset($_POST['_composable_categories']) ? array_map('absint', $_POST['_composable_categories']) : [];
update_post_meta($post_id, '_composable_categories', $categories);
// Save tags
$tags = isset($_POST['_composable_tags']) ? array_map('absint', $_POST['_composable_tags']) : [];
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);
}
}

View File

@@ -2,107 +2,114 @@
/** /**
* Admin Settings * Admin Settings
* *
* @package WC_Composable_Product * @package Magdev\WcComposableProduct
*/ */
namespace WC_Composable_Product\Admin; namespace Magdev\WcComposableProduct\Admin;
defined('ABSPATH') || exit; defined( 'ABSPATH' ) || exit;
/** /**
* Settings class * Settings class
*/ */
class Settings extends \WC_Settings_Page { class Settings extends \WC_Settings_Page {
/** /**
* Constructor * Constructor
*/ */
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();
} }
/** /**
* Get settings array * Get settings array
* *
* @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' => __('Show Product Images', 'wc-composable-product'), 'title' => __( 'Include Non-Public Products', 'wc-composable-product' ),
'desc' => __('Display product images in the selection interface.', '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_show_images', 'id' => 'wc_composable_include_unpublished',
'type' => 'checkbox', 'type' => 'checkbox',
'default' => 'yes', 'default' => 'no',
], ),
[ array(
'title' => __('Show Product Prices', 'wc-composable-product'), 'title' => __( 'Show Product Images', 'wc-composable-product' ),
'desc' => __('Display individual product prices in the selection interface.', 'wc-composable-product'), 'desc' => __( 'Display product images in the selection interface.', 'wc-composable-product' ),
'id' => 'wc_composable_show_prices', 'id' => 'wc_composable_show_images',
'type' => 'checkbox', 'type' => 'checkbox',
'default' => 'yes', 'default' => 'yes',
], ),
[ array(
'title' => __('Show Total Price', 'wc-composable-product'), 'title' => __( 'Show Product Prices', 'wc-composable-product' ),
'desc' => __('Display the total price as customers make selections.', 'wc-composable-product'), 'desc' => __( 'Display individual product prices in the selection interface.', 'wc-composable-product' ),
'id' => 'wc_composable_show_total', 'id' => 'wc_composable_show_prices',
'type' => 'checkbox', 'type' => 'checkbox',
'default' => 'yes', 'default' => 'yes',
], ),
[ array(
'type' => 'sectionend', 'title' => __( 'Show Total Price', 'wc-composable-product' ),
'id' => 'wc_composable_settings', 'desc' => __( 'Display the total price as customers make selections.', 'wc-composable-product' ),
], 'id' => 'wc_composable_show_total',
]; 'type' => 'checkbox',
'default' => 'yes',
),
array(
'type' => 'sectionend',
'id' => 'wc_composable_settings',
),
);
return apply_filters('wc_composable_settings', $settings); return apply_filters( 'wc_composable_settings', $settings );
} }
/** /**
* Output the settings * Output the settings
*/ */
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 );
} }
/** /**
* Save settings * Save settings
*/ */
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 );
} }
} }

228
includes/CartHandler.php Normal file
View File

@@ -0,0 +1,228 @@
<?php
/**
* Cart Handler
*
* @package Magdev\WcComposableProduct
*/
namespace Magdev\WcComposableProduct;
defined( 'ABSPATH' ) || exit;
/**
* Cart Handler Class
*
* Handles adding composable products to cart and calculating prices
*/
class CartHandler {
/**
* Stock manager instance
*
* @var StockManager
*/
private $stock_manager;
/**
* Constructor
*/
public function __construct() {
$this->stock_manager = new StockManager();
add_filter( 'woocommerce_add_to_cart_validation', array( $this, 'validate_add_to_cart' ), 10, 3 );
add_filter( 'woocommerce_add_cart_item_data', array( $this, 'add_cart_item_data' ), 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', array( $this, 'display_cart_item_data' ), 10, 2 );
add_action( 'woocommerce_before_calculate_totals', array( $this, 'calculate_cart_item_price' ) );
add_action( 'woocommerce_single_product_summary', array( $this, 'render_product_selector' ), 25 );
add_action( 'woocommerce_checkout_create_order_line_item', array( $this->stock_manager, 'store_selected_products_in_order' ), 10, 3 );
add_filter( 'woocommerce_is_purchasable', array( $this, 'hide_default_add_to_cart' ), 10, 2 );
}
/**
* Hide default WooCommerce add to cart button for composable products
*
* @param bool $is_purchasable Is purchasable status
* @param \WC_Product $product Product object
* @return bool
*/
public function hide_default_add_to_cart( $is_purchasable, $product ) {
if ( $product && $product->get_type() === 'composable' ) {
return false;
}
return $is_purchasable;
}
/**
* Render product selector on product page
*/
public function render_product_selector() {
global $product;
if ( $product && $product->get_type() === 'composable' ) {
ProductSelector::render( $product );
}
}
/**
* Validate add to cart
*
* @param bool $passed Validation status
* @param int $product_id Product ID
* @param int $quantity Quantity
* @return bool
*/
public function validate_add_to_cart( $passed, $product_id, $quantity ) {
$product = wc_get_product( $product_id );
if ( ! $product || $product->get_type() !== 'composable' ) {
return $passed;
}
// Check if selected products are provided.
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by WooCommerce add-to-cart handler.
if ( ! isset( $_POST['composable_products'] ) || empty( $_POST['composable_products'] ) ) {
wc_add_notice( __( 'Please select at least one product.', 'wc-composable-product' ), 'error' );
return false;
}
// 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();
// Validate selection limit
if ( count( $selected_products ) > $selection_limit ) {
/* translators: %d: selection limit */
wc_add_notice( sprintf( __( 'You can select a maximum of %d products.', 'wc-composable-product' ), $selection_limit ), 'error' );
return false;
}
if ( count( $selected_products ) === 0 ) {
wc_add_notice( __( 'Please select at least one product.', 'wc-composable-product' ), 'error' );
return false;
}
// Validate that selected products are valid
$available_products = $product->get_available_products();
$available_ids = array_map(
function ( $p ) {
return $p->get_id();
},
$available_products
);
foreach ( $selected_products as $selected_id ) {
if ( ! in_array( $selected_id, $available_ids, true ) ) {
wc_add_notice( __( 'One or more selected products are not available.', 'wc-composable-product' ), 'error' );
return false;
}
}
// Validate stock availability
$stock_validation = $this->stock_manager->validate_stock_availability( $selected_products, $quantity );
if ( true !== $stock_validation ) {
wc_add_notice( $stock_validation, 'error' );
return false;
}
return $passed;
}
/**
* Add cart item data
*
* @param array $cart_item_data Cart item data
* @param int $product_id Product ID
* @return array
*/
public function add_cart_item_data( $cart_item_data, $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product || $product->get_type() !== 'composable' ) {
return $cart_item_data;
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by WooCommerce add-to-cart handler.
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;
// Make cart item unique.
$cart_item_data['unique_key'] = md5( wp_json_encode( $selected_products ) . time() );
}
return $cart_item_data;
}
/**
* Get cart item from session
*
* @param array $cart_item Cart item
* @param array $values Values from session
* @return array
*/
public function get_cart_item_from_session( $cart_item, $values ) {
if ( isset( $values['composable_products'] ) ) {
$cart_item['composable_products'] = $values['composable_products'];
}
return $cart_item;
}
/**
* Display cart item data
*
* @param array $item_data Item data
* @param array $cart_item Cart item
* @return array
*/
public function display_cart_item_data( $item_data, $cart_item ) {
if ( isset( $cart_item['composable_products'] ) && ! empty( $cart_item['composable_products'] ) ) {
$product_names = array();
foreach ( $cart_item['composable_products'] as $product_id ) {
$product = wc_get_product( $product_id );
if ( $product ) {
$product_names[] = $product->get_name();
}
}
if ( ! empty( $product_names ) ) {
$item_data[] = array(
'key' => __( 'Selected Products', 'wc-composable-product' ),
'value' => implode( ', ', $product_names ),
);
}
}
return $item_data;
}
/**
* Calculate cart item price
*
* @param \WC_Cart $cart Cart object
*/
public function calculate_cart_item_price( $cart ) {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return;
}
// Use static flag to prevent multiple executions within the same request
static $already_calculated = false;
if ( $already_calculated ) {
return;
}
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['composable_products'] ) ) {
$product = $cart_item['data'];
$price = $product->calculate_composed_price( $cart_item['composable_products'] );
$cart_item['data']->set_price( $price );
}
}
}
$already_calculated = true;
}
}

View File

@@ -1,221 +0,0 @@
<?php
/**
* Cart Handler
*
* @package WC_Composable_Product
*/
namespace WC_Composable_Product;
defined('ABSPATH') || exit;
/**
* Cart Handler Class
*
* Handles adding composable products to cart and calculating prices
*/
class Cart_Handler {
/**
* Stock manager instance
*
* @var Stock_Manager
*/
private $stock_manager;
/**
* Constructor
*/
public function __construct() {
$this->stock_manager = new Stock_Manager();
add_filter('woocommerce_add_to_cart_validation', [$this, 'validate_add_to_cart'], 10, 3);
add_filter('woocommerce_add_cart_item_data', [$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_item_data', [$this, 'display_cart_item_data'], 10, 2);
add_action('woocommerce_before_calculate_totals', [$this, 'calculate_cart_item_price']);
add_action('woocommerce_single_product_summary', [$this, 'render_product_selector'], 25);
add_action('woocommerce_checkout_create_order_line_item', [$this->stock_manager, 'store_selected_products_in_order'], 10, 3);
add_filter('woocommerce_is_purchasable', [$this, 'hide_default_add_to_cart'], 10, 2);
}
/**
* Hide default WooCommerce add to cart button for composable products
*
* @param bool $is_purchasable Is purchasable status
* @param \WC_Product $product Product object
* @return bool
*/
public function hide_default_add_to_cart($is_purchasable, $product) {
if ($product && $product->get_type() === 'composable') {
return false;
}
return $is_purchasable;
}
/**
* Render product selector on product page
*/
public function render_product_selector() {
global $product;
if ($product && $product->get_type() === 'composable') {
Product_Selector::render($product);
}
}
/**
* Validate add to cart
*
* @param bool $passed Validation status
* @param int $product_id Product ID
* @param int $quantity Quantity
* @return bool
*/
public function validate_add_to_cart($passed, $product_id, $quantity) {
$product = wc_get_product($product_id);
if (!$product || $product->get_type() !== 'composable') {
return $passed;
}
// Check if selected products are provided
if (!isset($_POST['composable_products']) || empty($_POST['composable_products'])) {
wc_add_notice(__('Please select at least one product.', 'wc-composable-product'), 'error');
return false;
}
$selected_products = array_map('absint', $_POST['composable_products']);
$selection_limit = $product->get_selection_limit();
// Validate selection limit
if (count($selected_products) > $selection_limit) {
/* translators: %d: selection limit */
wc_add_notice(sprintf(__('You can select a maximum of %d products.', 'wc-composable-product'), $selection_limit), 'error');
return false;
}
if (count($selected_products) === 0) {
wc_add_notice(__('Please select at least one product.', 'wc-composable-product'), 'error');
return false;
}
// Validate that selected products are valid
$available_products = $product->get_available_products();
$available_ids = array_map(function($p) {
return $p->get_id();
}, $available_products);
foreach ($selected_products as $selected_id) {
if (!in_array($selected_id, $available_ids)) {
wc_add_notice(__('One or more selected products are not available.', 'wc-composable-product'), 'error');
return false;
}
}
// Validate stock availability
$stock_validation = $this->stock_manager->validate_stock_availability($selected_products, $quantity);
if ($stock_validation !== true) {
wc_add_notice($stock_validation, 'error');
return false;
}
return $passed;
}
/**
* Add cart item data
*
* @param array $cart_item_data Cart item data
* @param int $product_id Product ID
* @return array
*/
public function add_cart_item_data($cart_item_data, $product_id) {
$product = wc_get_product($product_id);
if (!$product || $product->get_type() !== 'composable') {
return $cart_item_data;
}
if (isset($_POST['composable_products']) && !empty($_POST['composable_products'])) {
$selected_products = array_map('absint', $_POST['composable_products']);
$cart_item_data['composable_products'] = $selected_products;
// Make cart item unique
$cart_item_data['unique_key'] = md5(json_encode($selected_products) . time());
}
return $cart_item_data;
}
/**
* Get cart item from session
*
* @param array $cart_item Cart item
* @param array $values Values from session
* @return array
*/
public function get_cart_item_from_session($cart_item, $values) {
if (isset($values['composable_products'])) {
$cart_item['composable_products'] = $values['composable_products'];
}
return $cart_item;
}
/**
* Display cart item data
*
* @param array $item_data Item data
* @param array $cart_item Cart item
* @return array
*/
public function display_cart_item_data($item_data, $cart_item) {
if (isset($cart_item['composable_products']) && !empty($cart_item['composable_products'])) {
$product_names = [];
foreach ($cart_item['composable_products'] as $product_id) {
$product = wc_get_product($product_id);
if ($product) {
$product_names[] = $product->get_name();
}
}
if (!empty($product_names)) {
$item_data[] = [
'key' => __('Selected Products', 'wc-composable-product'),
'value' => implode(', ', $product_names),
];
}
}
return $item_data;
}
/**
* Calculate cart item price
*
* @param \WC_Cart $cart Cart object
*/
public function calculate_cart_item_price($cart) {
if (is_admin() && !defined('DOING_AJAX')) {
return;
}
// Use static flag to prevent multiple executions within the same request
static $already_calculated = false;
if ($already_calculated) {
return;
}
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['composable_products'])) {
$product = $cart_item['data'];
$price = $product->calculate_composed_price($cart_item['composable_products']);
$cart_item['data']->set_price($price);
}
}
}
$already_calculated = true;
}
}

View File

@@ -2,232 +2,245 @@
/** /**
* Main Plugin Class * Main Plugin Class
* *
* @package WC_Composable_Product * @package Magdev\WcComposableProduct
*/ */
namespace WC_Composable_Product; namespace Magdev\WcComposableProduct;
defined('ABSPATH') || exit; defined( 'ABSPATH' ) || exit;
/** /**
* Main plugin class - Singleton pattern * Main plugin class - Singleton pattern
*/ */
class Plugin { class Plugin {
/** /**
* The single instance of the class * The single instance of the class
* *
* @var Plugin * @var Plugin
*/ */
protected static $instance = null; protected static $instance = null;
/** /**
* Twig environment * Twig environment
* *
* @var \Twig\Environment * @var \Twig\Environment
*/ */
private $twig = null; private $twig = null;
/** /**
* Main Plugin Instance * Main Plugin Instance
* *
* Ensures only one instance is loaded or can be loaded. * Ensures only one instance is loaded or can be loaded.
* *
* @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;
} }
/** /**
* Constructor * Constructor
*/ */
private function __construct() { private function __construct() {
$this->init_hooks(); $this->init_hooks();
$this->init_twig(); $this->init_twig();
$this->includes(); $this->includes();
} }
/** /**
* Hook into WordPress and WooCommerce * Hook into WordPress and WooCommerce
*/ */
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(
'cache' => WC_COMPOSABLE_PRODUCT_PATH . 'cache', $loader,
'auto_reload' => true, array(
'debug' => defined('WP_DEBUG') && WP_DEBUG, 'cache' => WC_COMPOSABLE_PRODUCT_PATH . 'cache',
]); 'auto_reload' => true,
'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' ) );
} }
/** /**
* Include required files * Include required files
*/ */
private function includes() { private function includes() {
// Note: Settings.php is NOT included here because it extends WC_Settings_Page // Note: Settings.php is NOT included here because it extends WC_Settings_Page
// which isn't loaded until later. It's included in add_settings_page() instead. // which isn't loaded until later. It's included in add_settings_page() instead.
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Admin/Product_Data.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Admin/ProductData.php';
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Product_Type.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/ProductType.php';
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Stock_Manager.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/StockManager.php';
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Cart_Handler.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/CartHandler.php';
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Product_Selector.php'; require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/ProductSelector.php';
// Initialize components // Initialize components
new Admin\Product_Data(); new Admin\ProductData();
new Cart_Handler(); new CartHandler();
} }
/** /**
* Add composable product type to selector * Add composable product type to selector
* *
* @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;
} }
/** /**
* Use custom product class for composable products * Use custom product class for composable products
* *
* @param string $classname Product class name * @param string $classname Product class name
* @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 = 'WC_Composable_Product\Product_Type'; $classname = 'Magdev\WcComposableProduct\ProductType';
} }
return $classname; return $classname;
} }
/** /**
* 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' ),
'currency_symbol' => get_woocommerce_currency_symbol(), 'min_items' => __( 'Please select at least one item', 'wc-composable-product' ),
'decimal_separator' => wc_get_price_decimal_separator(), ),
'thousand_separator' => wc_get_price_thousand_separator(), 'price_format' => array(
'decimals' => wc_get_price_decimals(), 'currency_symbol' => get_woocommerce_currency_symbol(),
'price_format' => get_woocommerce_price_format(), 'decimal_separator' => wc_get_price_decimal_separator(),
], 'thousand_separator' => wc_get_price_thousand_separator(),
]); 'decimals' => wc_get_price_decimals(),
} '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
); );
} }
} }
} }
/** /**
* Add settings page to WooCommerce * Add settings page to WooCommerce
* *
* @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();
return $settings; return $settings;
} }
/** /**
* Get Twig environment * Get Twig environment
* *
* @return \Twig\Environment * @return \Twig\Environment
*/ */
public function get_twig() { public function get_twig() {
return $this->twig; return $this->twig;
} }
/** /**
* Render a Twig template * Render a Twig template
* *
* @param string $template Template name * @param string $template Template name
* @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

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

254
includes/ProductType.php Normal file
View File

@@ -0,0 +1,254 @@
<?php
/**
* Composable Product Type
*
* @package Magdev\WcComposableProduct
*/
namespace Magdev\WcComposableProduct;
defined( 'ABSPATH' ) || exit;
/**
* Composable Product Type Class
*/
class ProductType extends \WC_Product {
/**
* Product type
*
* @var string
*/
protected $product_type = 'composable';
/**
* Constructor
*
* @param mixed $product Product ID or object
*/
public function __construct( $product = 0 ) {
$this->supports[] = 'ajax_add_to_cart';
parent::__construct( $product );
}
/**
* Get product type
*
* @return string
*/
public function get_type() {
return 'composable';
}
/**
* Get selection limit
*
* @return int
*/
public function get_selection_limit() {
$limit = $this->get_meta( '_composable_selection_limit', true );
if ( empty( $limit ) ) {
$limit = get_option( 'wc_composable_default_limit', 5 );
}
return absint( $limit );
}
/**
* Get pricing mode
*
* @return string 'fixed' or 'sum'
*/
public function get_pricing_mode() {
$mode = $this->get_meta( '_composable_pricing_mode', true );
if ( empty( $mode ) ) {
$mode = get_option( 'wc_composable_default_pricing', 'sum' );
}
return $mode;
}
/**
* Get product selection criteria
*
* @return array
*/
public function get_selection_criteria() {
$type = $this->get_meta( '_composable_criteria_type', true );
$categories = $this->get_meta( '_composable_categories', true );
$tags = $this->get_meta( '_composable_tags', 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 : '',
);
}
/**
* Check if product is purchasable
*
* @return bool
*/
public function is_purchasable() {
return true;
}
/**
* Check if product is sold individually
*
* @return bool
*/
public function is_sold_individually() {
return true;
}
/**
* Check if non-public products should be included
*
* @return bool
*/
public function should_include_unpublished() {
$per_product = $this->get_meta( '_composable_include_unpublished', true );
if ( 'yes' === $per_product ) {
return true;
}
if ( 'no' === $per_product ) {
return false;
}
return 'yes' === get_option( 'wc_composable_include_unpublished', 'no' );
}
/**
* Get available products based on criteria
*
* @return array Array of WC_Product objects
*/
public function get_available_products() {
$criteria = $this->get_selection_criteria();
$include_unpublished = $this->should_include_unpublished();
$args = array(
'post_type' => 'product',
'posts_per_page' => -1,
'post_status' => $include_unpublished ? array( 'publish', 'draft', 'private' ) : 'publish',
'orderby' => 'title',
'order' => 'ASC',
);
// Exclude composable products using the product_type taxonomy
// (WooCommerce stores product types as taxonomy terms, NOT as postmeta)
$args['tax_query'] = array(
'relation' => 'AND',
array(
'taxonomy' => 'product_type',
'field' => 'slug',
'terms' => array( 'composable' ),
'operator' => 'NOT IN',
),
);
switch ( $criteria['type'] ) {
case 'category':
if ( ! empty( $criteria['categories'] ) ) {
$args['tax_query'][] = array(
'taxonomy' => 'product_cat',
'field' => 'term_id',
'terms' => $criteria['categories'],
'operator' => 'IN',
);
}
break;
case 'tag':
if ( ! empty( $criteria['tags'] ) ) {
$args['tax_query'][] = array(
'taxonomy' => 'product_tag',
'field' => 'term_id',
'terms' => $criteria['tags'],
'operator' => 'IN',
);
}
break;
case 'sku':
if ( ! empty( $criteria['skus'] ) ) {
$skus = array_map( 'trim', explode( ',', $criteria['skus'] ) );
$args['meta_query'] = array(
array(
'key' => '_sku',
'value' => $skus,
'compare' => 'IN',
),
);
}
break;
}
$query = new \WP_Query( $args );
$products = array();
if ( $query->have_posts() ) {
foreach ( $query->posts as $post ) {
$product = wc_get_product( $post->ID );
if ( ! $product ) {
continue;
}
// Handle variable products by including their variations
if ( $product->is_type( 'variable' ) ) {
$variation_ids = $product->get_children();
foreach ( $variation_ids as $variation_id ) {
$variation = wc_get_product( $variation_id );
if ( $variation && ( $include_unpublished || $variation->is_purchasable() ) ) {
$products[] = $variation;
}
}
} elseif ( $include_unpublished || $product->is_purchasable() ) {
$products[] = $product;
}
}
}
wp_reset_postdata();
return $products;
}
/**
* Calculate price based on selected products
*
* @param array $selected_products Array of product IDs
* @return float
*/
public function calculate_composed_price( $selected_products ) {
$pricing_mode = $this->get_pricing_mode();
if ( 'fixed' === $pricing_mode ) {
return floatval( $this->get_regular_price() );
}
$total = 0;
foreach ( $selected_products as $product_id ) {
$product = wc_get_product( $product_id );
if ( $product ) {
$total += floatval( $product->get_price() );
}
}
return $total;
}
/**
* Add to cart validation
*
* @param int $product_id Product ID
* @param int $quantity Quantity
* @param int $variation_id Variation ID
* @param array $variations Variations
* @param array $cart_item_data Cart item data
* @return bool
*/
public function add_to_cart_validation( $product_id, $quantity, $variation_id = 0, $variations = array(), $cart_item_data = array() ) {
return true;
}
}

View File

@@ -1,77 +0,0 @@
<?php
/**
* Product Selector
*
* @package WC_Composable_Product
*/
namespace WC_Composable_Product;
defined('ABSPATH') || exit;
/**
* Product Selector Class
*
* Handles rendering the product selection interface
*/
class Product_Selector {
/**
* Render product selector
*
* @param Product_Type $product Composable product
*/
public static function render($product) {
if (!$product || $product->get_type() !== 'composable') {
return;
}
$available_products = $product->get_available_products();
$selection_limit = $product->get_selection_limit();
$pricing_mode = $product->get_pricing_mode();
$show_images = get_option('wc_composable_show_images', 'yes') === 'yes';
$show_prices = get_option('wc_composable_show_prices', 'yes') === 'yes';
$show_total = get_option('wc_composable_show_total', 'yes') === 'yes';
// Get stock manager for stock information
$stock_manager = new Stock_Manager();
// Prepare product data for template
$products_data = [];
foreach ($available_products as $available_product) {
$stock_info = $stock_manager->get_product_stock_info($available_product->get_id());
$products_data[] = [
'id' => $available_product->get_id(),
'name' => $available_product->get_name(),
'price' => $available_product->get_price(),
'price_html' => $available_product->get_price_html(),
'image_url' => wp_get_attachment_image_url($available_product->get_image_id(), 'thumbnail'),
'permalink' => $available_product->get_permalink(),
'stock_status' => $stock_info['stock_status'],
'in_stock' => $stock_info['in_stock'],
'stock_quantity' => $stock_info['stock_quantity'],
'managing_stock' => $stock_info['managing_stock'],
'backorders_allowed' => $stock_info['backorders_allowed'],
];
}
$context = [
'product_id' => $product->get_id(),
'products' => $products_data,
'selection_limit' => $selection_limit,
'pricing_mode' => $pricing_mode,
'show_images' => $show_images,
'show_prices' => $show_prices,
'show_total' => $show_total,
'fixed_price' => $product->get_price(),
'fixed_price_html' => wc_price($product->get_price()),
'zero_price_html' => wc_price(0),
'currency_symbol' => get_woocommerce_currency_symbol(),
];
// Render template
$plugin = Plugin::instance();
echo $plugin->render_template('product-selector.twig', $context);
}
}

View File

@@ -1,232 +0,0 @@
<?php
/**
* Composable Product Type
*
* @package WC_Composable_Product
*/
namespace WC_Composable_Product;
defined('ABSPATH') || exit;
/**
* Composable Product Type Class
*/
class Product_Type extends \WC_Product {
/**
* Product type
*
* @var string
*/
protected $product_type = 'composable';
/**
* Constructor
*
* @param mixed $product Product ID or object
*/
public function __construct($product = 0) {
$this->supports[] = 'ajax_add_to_cart';
parent::__construct($product);
}
/**
* Get product type
*
* @return string
*/
public function get_type() {
return 'composable';
}
/**
* Get selection limit
*
* @return int
*/
public function get_selection_limit() {
$limit = $this->get_meta('_composable_selection_limit', true);
if (empty($limit)) {
$limit = get_option('wc_composable_default_limit', 5);
}
return absint($limit);
}
/**
* Get pricing mode
*
* @return string 'fixed' or 'sum'
*/
public function get_pricing_mode() {
$mode = $this->get_meta('_composable_pricing_mode', true);
if (empty($mode)) {
$mode = get_option('wc_composable_default_pricing', 'sum');
}
return $mode;
}
/**
* Get product selection criteria
*
* @return array
*/
public function get_selection_criteria() {
return [
'type' => $this->get_meta('_composable_criteria_type', true) ?: 'category',
'categories' => $this->get_meta('_composable_categories', true) ?: [],
'tags' => $this->get_meta('_composable_tags', true) ?: [],
'skus' => $this->get_meta('_composable_skus', true) ?: '',
];
}
/**
* Check if product is purchasable
*
* @return bool
*/
public function is_purchasable() {
return true;
}
/**
* Check if product is sold individually
*
* @return bool
*/
public function is_sold_individually() {
return true;
}
/**
* Get available products based on criteria
*
* @return array Array of WC_Product objects
*/
public function get_available_products() {
$criteria = $this->get_selection_criteria();
$args = [
'post_type' => 'product',
'posts_per_page' => -1,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC',
];
// Exclude composable products using the product_type taxonomy
// (WooCommerce stores product types as taxonomy terms, NOT as postmeta)
$args['tax_query'] = [
'relation' => 'AND',
[
'taxonomy' => 'product_type',
'field' => 'slug',
'terms' => ['composable'],
'operator' => 'NOT IN',
],
];
switch ($criteria['type']) {
case 'category':
if (!empty($criteria['categories'])) {
$args['tax_query'][] = [
'taxonomy' => 'product_cat',
'field' => 'term_id',
'terms' => $criteria['categories'],
'operator' => 'IN',
];
}
break;
case 'tag':
if (!empty($criteria['tags'])) {
$args['tax_query'][] = [
'taxonomy' => 'product_tag',
'field' => 'term_id',
'terms' => $criteria['tags'],
'operator' => 'IN',
];
}
break;
case 'sku':
if (!empty($criteria['skus'])) {
$skus = array_map('trim', explode(',', $criteria['skus']));
$args['meta_query'] = [
[
'key' => '_sku',
'value' => $skus,
'compare' => 'IN',
],
];
}
break;
}
$query = new \WP_Query($args);
$products = [];
if ($query->have_posts()) {
foreach ($query->posts as $post) {
$product = wc_get_product($post->ID);
if (!$product) {
continue;
}
// Handle variable products by including their variations
if ($product->is_type('variable')) {
$variation_ids = $product->get_children();
foreach ($variation_ids as $variation_id) {
$variation = wc_get_product($variation_id);
if ($variation && $variation->is_purchasable()) {
$products[] = $variation;
}
}
} elseif ($product->is_purchasable()) {
$products[] = $product;
}
}
}
wp_reset_postdata();
return $products;
}
/**
* Calculate price based on selected products
*
* @param array $selected_products Array of product IDs
* @return float
*/
public function calculate_composed_price($selected_products) {
$pricing_mode = $this->get_pricing_mode();
if ($pricing_mode === 'fixed') {
return floatval($this->get_regular_price());
}
$total = 0;
foreach ($selected_products as $product_id) {
$product = wc_get_product($product_id);
if ($product) {
$total += floatval($product->get_price());
}
}
return $total;
}
/**
* Add to cart validation
*
* @param int $product_id Product ID
* @param int $quantity Quantity
* @param int $variation_id Variation ID
* @param array $variations Variations
* @param array $cart_item_data Cart item data
* @return bool
*/
public function add_to_cart_validation($product_id, $quantity, $variation_id = 0, $variations = [], $cart_item_data = []) {
return true;
}
}

283
includes/StockManager.php Normal file
View File

@@ -0,0 +1,283 @@
<?php
/**
* Stock Manager
*
* @package Magdev\WcComposableProduct
*/
namespace Magdev\WcComposableProduct;
defined( 'ABSPATH' ) || exit;
/**
* Stock Manager Class
*
* Handles stock management for composable products
*/
class StockManager {
/**
* Constructor
*/
public function __construct() {
// Hook into order completion to reduce stock
add_action( 'woocommerce_order_status_completed', array( $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
add_action( 'woocommerce_order_status_cancelled', array( $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
add_filter( 'woocommerce_can_reduce_order_stock', array( $this, 'prevent_composable_stock_reduction' ), 10, 2 );
}
/**
* Validate stock availability for selected products
*
* @param array $selected_product_ids Array of product IDs
* @param int $quantity Quantity of composable product being added
* @return bool|string True if in stock, error message otherwise
*/
public function validate_stock_availability( $selected_product_ids, $quantity = 1 ) {
foreach ( $selected_product_ids as $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product ) {
continue;
}
// Skip stock check if stock management is disabled for this product
if ( ! $product->managing_stock() ) {
continue;
}
$stock_quantity = $product->get_stock_quantity();
// Check if product is in stock
if ( ! $product->is_in_stock() ) {
return sprintf(
/* translators: %s: product name */
__( '"%s" is out of stock and cannot be selected.', 'wc-composable-product' ),
$product->get_name()
);
}
// Check if enough stock is available
if ( null !== $stock_quantity && $stock_quantity < $quantity ) {
return sprintf(
/* translators: 1: product name, 2: stock quantity */
__( 'Only %2$d of "%1$s" are available in stock.', 'wc-composable-product' ),
$product->get_name(),
$stock_quantity
);
}
// Check for backorders
if ( $product->backorders_allowed() ) {
continue;
}
}
return true;
}
/**
* Check if a product has sufficient stock
*
* @param int $product_id Product ID
* @param int $required_quantity Required quantity
* @return array Stock information [in_stock, stock_quantity, backorders_allowed]
*/
public function get_product_stock_info( $product_id, $required_quantity = 1 ) {
$product = wc_get_product( $product_id );
if ( ! $product ) {
return array(
'in_stock' => false,
'stock_quantity' => 0,
'backorders_allowed' => false,
'stock_status' => 'outofstock',
);
}
$stock_quantity = $product->get_stock_quantity();
$managing_stock = $product->managing_stock();
return array(
'in_stock' => $product->is_in_stock(),
'stock_quantity' => $stock_quantity,
'backorders_allowed' => $product->backorders_allowed(),
'stock_status' => $product->get_stock_status(),
'managing_stock' => $managing_stock,
'has_enough_stock' => ! $managing_stock || null === $stock_quantity || $stock_quantity >= $required_quantity,
);
}
/**
* Reduce stock for composable products when order is completed
*
* @param int $order_id Order ID
*/
public function reduce_stock_on_order_complete( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
// Check if stock has already been reduced
if ( $order->get_meta( '_composable_stock_reduced', true ) ) {
return;
}
foreach ( $order->get_items() as $item ) {
$product = $item->get_product();
if ( ! $product || $product->get_type() !== 'composable' ) {
continue;
}
// Get selected products from order item meta
$selected_products = $item->get_meta( '_composable_products', true );
if ( empty( $selected_products ) || ! is_array( $selected_products ) ) {
continue;
}
$quantity = $item->get_quantity();
// Reduce stock for each selected product
foreach ( $selected_products as $product_id ) {
$selected_product = wc_get_product( $product_id );
if ( ! $selected_product || ! $selected_product->managing_stock() ) {
continue;
}
$stock_quantity = $selected_product->get_stock_quantity();
if ( null !== $stock_quantity ) {
$new_stock = $stock_quantity - $quantity;
$selected_product->set_stock_quantity( $new_stock );
$selected_product->save();
// Add order note
$order->add_order_note(
sprintf(
/* translators: 1: product name, 2: quantity, 3: remaining stock */
__( 'Stock reduced for "%1$s": -%2$d (remaining: %3$d)', 'wc-composable-product' ),
$selected_product->get_name(),
$quantity,
$new_stock
)
);
}
}
}
// Mark stock as reduced
$order->update_meta_data( '_composable_stock_reduced', true );
$order->save();
}
/**
* Restore stock when order is cancelled or refunded
*
* @param int $order_id Order ID
*/
public function restore_stock_on_order_cancel( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
// Check if stock was reduced
if ( ! $order->get_meta( '_composable_stock_reduced', true ) ) {
return;
}
foreach ( $order->get_items() as $item ) {
$product = $item->get_product();
if ( ! $product || $product->get_type() !== 'composable' ) {
continue;
}
// Get selected products from order item meta
$selected_products = $item->get_meta( '_composable_products', true );
if ( empty( $selected_products ) || ! is_array( $selected_products ) ) {
continue;
}
$quantity = $item->get_quantity();
// Restore stock for each selected product
foreach ( $selected_products as $product_id ) {
$selected_product = wc_get_product( $product_id );
if ( ! $selected_product || ! $selected_product->managing_stock() ) {
continue;
}
$stock_quantity = $selected_product->get_stock_quantity();
if ( null !== $stock_quantity ) {
$new_stock = $stock_quantity + $quantity;
$selected_product->set_stock_quantity( $new_stock );
$selected_product->save();
// Add order note
$order->add_order_note(
sprintf(
/* translators: 1: product name, 2: quantity, 3: new stock */
__( 'Stock restored for "%1$s": +%2$d (total: %3$d)', 'wc-composable-product' ),
$selected_product->get_name(),
$quantity,
$new_stock
)
);
}
}
}
// Mark stock as restored
$order->update_meta_data( '_composable_stock_reduced', false );
$order->save();
}
/**
* Prevent WooCommerce from reducing stock for composable products
* We handle stock reduction manually for selected products
*
* @param bool $reduce_stock Whether to reduce stock
* @param \WC_Order $order Order object
* @return bool
*/
public function prevent_composable_stock_reduction( $reduce_stock, $order ) {
foreach ( $order->get_items() as $item ) {
$product = $item->get_product();
if ( $product && $product->get_type() === 'composable' ) {
// We'll handle stock reduction manually
return false;
}
}
return $reduce_stock;
}
/**
* Store selected products in order item meta
*
* @param \WC_Order_Item_Product $item Order item
* @param string $cart_item_key Cart item key
* @param array $values Cart item values
*/
public function store_selected_products_in_order( $item, $cart_item_key, $values ) {
if ( isset( $values['composable_products'] ) && ! empty( $values['composable_products'] ) ) {
$item->add_meta_data( '_composable_products', $values['composable_products'], true );
}
}
}

View File

@@ -1,283 +0,0 @@
<?php
/**
* Stock Manager
*
* @package WC_Composable_Product
*/
namespace WC_Composable_Product;
defined('ABSPATH') || exit;
/**
* Stock Manager Class
*
* Handles stock management for composable products
*/
class Stock_Manager {
/**
* Constructor
*/
public function __construct() {
// 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_processing', [$this, 'reduce_stock_on_order_complete'], 10, 1);
// 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_refunded', [$this, 'restore_stock_on_order_cancel'], 10, 1);
// Prevent double stock reduction
add_filter('woocommerce_can_reduce_order_stock', [$this, 'prevent_composable_stock_reduction'], 10, 2);
}
/**
* Validate stock availability for selected products
*
* @param array $selected_product_ids Array of product IDs
* @param int $quantity Quantity of composable product being added
* @return bool|string True if in stock, error message otherwise
*/
public function validate_stock_availability($selected_product_ids, $quantity = 1) {
foreach ($selected_product_ids as $product_id) {
$product = wc_get_product($product_id);
if (!$product) {
continue;
}
// Skip stock check if stock management is disabled for this product
if (!$product->managing_stock()) {
continue;
}
$stock_quantity = $product->get_stock_quantity();
// Check if product is in stock
if (!$product->is_in_stock()) {
return sprintf(
/* translators: %s: product name */
__('"%s" is out of stock and cannot be selected.', 'wc-composable-product'),
$product->get_name()
);
}
// Check if enough stock is available
if ($stock_quantity !== null && $stock_quantity < $quantity) {
return sprintf(
/* translators: 1: product name, 2: stock quantity */
__('Only %2$d of "%1$s" are available in stock.', 'wc-composable-product'),
$product->get_name(),
$stock_quantity
);
}
// Check for backorders
if ($product->backorders_allowed()) {
continue;
}
}
return true;
}
/**
* Check if a product has sufficient stock
*
* @param int $product_id Product ID
* @param int $required_quantity Required quantity
* @return array Stock information [in_stock, stock_quantity, backorders_allowed]
*/
public function get_product_stock_info($product_id, $required_quantity = 1) {
$product = wc_get_product($product_id);
if (!$product) {
return [
'in_stock' => false,
'stock_quantity' => 0,
'backorders_allowed' => false,
'stock_status' => 'outofstock',
];
}
$stock_quantity = $product->get_stock_quantity();
$managing_stock = $product->managing_stock();
return [
'in_stock' => $product->is_in_stock(),
'stock_quantity' => $stock_quantity,
'backorders_allowed' => $product->backorders_allowed(),
'stock_status' => $product->get_stock_status(),
'managing_stock' => $managing_stock,
'has_enough_stock' => !$managing_stock || $stock_quantity === null || $stock_quantity >= $required_quantity,
];
}
/**
* Reduce stock for composable products when order is completed
*
* @param int $order_id Order ID
*/
public function reduce_stock_on_order_complete($order_id) {
$order = wc_get_order($order_id);
if (!$order) {
return;
}
// Check if stock has already been reduced
if ($order->get_meta('_composable_stock_reduced', true)) {
return;
}
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if (!$product || $product->get_type() !== 'composable') {
continue;
}
// Get selected products from order item meta
$selected_products = $item->get_meta('_composable_products', true);
if (empty($selected_products) || !is_array($selected_products)) {
continue;
}
$quantity = $item->get_quantity();
// Reduce stock for each selected product
foreach ($selected_products as $product_id) {
$selected_product = wc_get_product($product_id);
if (!$selected_product || !$selected_product->managing_stock()) {
continue;
}
$stock_quantity = $selected_product->get_stock_quantity();
if ($stock_quantity !== null) {
$new_stock = $stock_quantity - $quantity;
$selected_product->set_stock_quantity($new_stock);
$selected_product->save();
// Add order note
$order->add_order_note(
sprintf(
/* translators: 1: product name, 2: quantity, 3: remaining stock */
__('Stock reduced for "%1$s": -%2$d (remaining: %3$d)', 'wc-composable-product'),
$selected_product->get_name(),
$quantity,
$new_stock
)
);
}
}
}
// Mark stock as reduced
$order->update_meta_data('_composable_stock_reduced', true);
$order->save();
}
/**
* Restore stock when order is cancelled or refunded
*
* @param int $order_id Order ID
*/
public function restore_stock_on_order_cancel($order_id) {
$order = wc_get_order($order_id);
if (!$order) {
return;
}
// Check if stock was reduced
if (!$order->get_meta('_composable_stock_reduced', true)) {
return;
}
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if (!$product || $product->get_type() !== 'composable') {
continue;
}
// Get selected products from order item meta
$selected_products = $item->get_meta('_composable_products', true);
if (empty($selected_products) || !is_array($selected_products)) {
continue;
}
$quantity = $item->get_quantity();
// Restore stock for each selected product
foreach ($selected_products as $product_id) {
$selected_product = wc_get_product($product_id);
if (!$selected_product || !$selected_product->managing_stock()) {
continue;
}
$stock_quantity = $selected_product->get_stock_quantity();
if ($stock_quantity !== null) {
$new_stock = $stock_quantity + $quantity;
$selected_product->set_stock_quantity($new_stock);
$selected_product->save();
// Add order note
$order->add_order_note(
sprintf(
/* translators: 1: product name, 2: quantity, 3: new stock */
__('Stock restored for "%1$s": +%2$d (total: %3$d)', 'wc-composable-product'),
$selected_product->get_name(),
$quantity,
$new_stock
)
);
}
}
}
// Mark stock as restored
$order->update_meta_data('_composable_stock_reduced', false);
$order->save();
}
/**
* Prevent WooCommerce from reducing stock for composable products
* We handle stock reduction manually for selected products
*
* @param bool $reduce_stock Whether to reduce stock
* @param \WC_Order $order Order object
* @return bool
*/
public function prevent_composable_stock_reduction($reduce_stock, $order) {
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && $product->get_type() === 'composable') {
// We'll handle stock reduction manually
return false;
}
}
return $reduce_stock;
}
/**
* Store selected products in order item meta
*
* @param \WC_Order_Item_Product $item Order item
* @param string $cart_item_key Cart item key
* @param array $values Cart item values
*/
public function store_selected_products_in_order($item, $cart_item_key, $values) {
if (isset($values['composable_products']) && !empty($values['composable_products'])) {
$item->add_meta_data('_composable_products', $values['composable_products'], true);
}
}
}

View File

@@ -91,7 +91,7 @@ msgstr "Den Gesamtpreis anzeigen, während Kunden Auswahlen treffen."
msgid "Composable product" msgid "Composable product"
msgstr "Zusammenstellbares Produkt" msgstr "Zusammenstellbares Produkt"
#: includes/Product_Selector.php, templates/product-selector.twig #: includes/ProductSelector.php, templates/product-selector.twig
msgid "Select Your Products" msgid "Select Your Products"
msgstr "Wählen Sie Ihre Produkte" msgstr "Wählen Sie Ihre Produkte"
@@ -111,115 +111,115 @@ msgstr "Gesamtpreis:"
msgid "Add to Cart" msgid "Add to Cart"
msgstr "In den Warenkorb" msgstr "In den Warenkorb"
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Please select at least one product." msgid "Please select at least one product."
msgstr "Bitte wählen Sie mindestens ein Produkt aus." msgstr "Bitte wählen Sie mindestens ein Produkt aus."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "You can select a maximum of %d products." msgid "You can select a maximum of %d products."
msgstr "Sie können maximal %d Produkte auswählen." msgstr "Sie können maximal %d Produkte auswählen."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "One or more selected products are not available." msgid "One or more selected products are not available."
msgstr "Ein oder mehrere ausgewählte Produkte sind nicht verfügbar." msgstr "Ein oder mehrere ausgewählte Produkte sind nicht verfügbar."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Selected Products" msgid "Selected Products"
msgstr "Ausgewählte Produkte" msgstr "Ausgewählte Produkte"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Composable Options" msgid "Composable Options"
msgstr "Zusammenstellungsoptionen" msgstr "Zusammenstellungsoptionen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Limit" msgid "Selection Limit"
msgstr "Auswahllimit" msgstr "Auswahllimit"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Maximum number of items customers can select. Leave empty to use global default." msgid "Maximum number of items customers can select. Leave empty to use global default."
msgstr "Maximale Anzahl der Artikel, die Kunden auswählen können. Leer lassen, um den globalen Standard zu verwenden." msgstr "Maximale Anzahl der Artikel, die Kunden auswählen können. Leer lassen, um den globalen Standard zu verwenden."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Pricing Mode" msgid "Pricing Mode"
msgstr "Preismodus" msgstr "Preismodus"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to calculate the price." msgid "How to calculate the price."
msgstr "Wie der Preis berechnet wird." msgstr "Wie der Preis berechnet wird."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Fixed Price" msgid "Fixed Price"
msgstr "Festpreis" msgstr "Festpreis"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter the fixed price for this composable product." msgid "Enter the fixed price for this composable product."
msgstr "Geben Sie den Festpreis für dieses zusammenstellbare Produkt ein." msgstr "Geben Sie den Festpreis für dieses zusammenstellbare Produkt ein."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Use global default" msgid "Use global default"
msgstr "Globalen Standard verwenden" msgstr "Globalen Standard verwenden"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Criteria" msgid "Selection Criteria"
msgstr "Auswahlkriterien" msgstr "Auswahlkriterien"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to select available products." msgid "How to select available products."
msgstr "Wie verfügbare Produkte ausgewählt werden." msgstr "Wie verfügbare Produkte ausgewählt werden."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Category" msgid "By Category"
msgstr "Nach Kategorie" msgstr "Nach Kategorie"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Tag" msgid "By Tag"
msgstr "Nach Schlagwort" msgstr "Nach Schlagwort"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By SKU" msgid "By SKU"
msgstr "Nach Artikelnummer" msgstr "Nach Artikelnummer"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Categories" msgid "Select Categories"
msgstr "Kategorien auswählen" msgstr "Kategorien auswählen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product categories to include." msgid "Select product categories to include."
msgstr "Produktkategorien auswählen, die einbezogen werden sollen." msgstr "Produktkategorien auswählen, die einbezogen werden sollen."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Tags" msgid "Select Tags"
msgstr "Schlagwörter auswählen" msgstr "Schlagwörter auswählen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product tags to include." msgid "Select product tags to include."
msgstr "Produkt-Schlagwörter auswählen, die einbezogen werden sollen." msgstr "Produkt-Schlagwörter auswählen, die einbezogen werden sollen."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Product SKUs" msgid "Product SKUs"
msgstr "Produkt-Artikelnummern" msgstr "Produkt-Artikelnummern"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter product SKUs separated by commas." msgid "Enter product SKUs separated by commas."
msgstr "Geben Sie Produkt-Artikelnummern durch Kommas getrennt ein." msgstr "Geben Sie Produkt-Artikelnummern durch Kommas getrennt ein."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "SKU-1, SKU-2, SKU-3" msgid "SKU-1, SKU-2, SKU-3"
msgstr "ART-1, ART-2, ART-3" msgstr "ART-1, ART-2, ART-3"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "\"%s\" is out of stock and cannot be selected." msgid "\"%s\" is out of stock and cannot be selected."
msgstr "\"%s\" ist nicht an Lager und kann nicht ausgewählt werden." msgstr "\"%s\" ist nicht an Lager und kann nicht ausgewählt werden."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Only %2$d of \"%1$s\" are available in stock." msgid "Only %2$d of \"%1$s\" are available in stock."
msgstr "Nur %2$d von \"%1$s\" sind an Lager verfügbar." msgstr "Nur %2$d von \"%1$s\" sind an Lager verfügbar."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)" msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)"
msgstr "Lagerbestand reduziert für \"%1$s\": -%2$d (verbleibend: %3$d)" msgstr "Lagerbestand reduziert für \"%1$s\": -%2$d (verbleibend: %3$d)"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)" msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)"
msgstr "Lagerbestand wiederhergestellt für \"%1$s\": +%2$d (gesamt: %3$d)" msgstr "Lagerbestand wiederhergestellt für \"%1$s\": +%2$d (gesamt: %3$d)"
@@ -242,3 +242,23 @@ msgstr "An Lager"
#: templates/product-selector.twig #: templates/product-selector.twig
msgid "No products available for selection. Please configure the product criteria in the admin panel." msgid "No products available for selection. Please configure the product criteria in the admin panel."
msgstr "Keine Produkte zur Auswahl verfügbar. Bitte konfigurieren Sie die Produktkriterien im Admin-Bereich." msgstr "Keine Produkte zur Auswahl verfügbar. Bitte konfigurieren Sie die Produktkriterien im Admin-Bereich."
#: includes/Admin/Settings.php, includes/Admin/ProductData.php
msgid "Include Non-Public Products"
msgstr "Nicht-öffentliche Produkte einbeziehen"
#: includes/Admin/Settings.php
msgid "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."
msgstr "Entwürfe und private Produkte in der Auswahl zusammenstellbarer Produkte anzeigen. Nützlich, wenn Produkte nur als Teil einer Zusammenstellung verkauft werden sollen, nicht einzeln."
#: includes/Admin/ProductData.php
msgid "Allow draft and private products in the selection. Useful when products should only be sold as part of a composition."
msgstr "Entwürfe und private Produkte in der Auswahl zulassen. Nützlich, wenn Produkte nur als Teil einer Zusammenstellung verkauft werden sollen."
#: includes/Admin/ProductData.php
msgid "Yes"
msgstr "Ja"
#: includes/Admin/ProductData.php
msgid "No"
msgstr "Nein"

View File

@@ -91,7 +91,7 @@ msgstr "Den Gesamtpreis anzeigen, während Kunden Auswahlen treffen."
msgid "Composable product" msgid "Composable product"
msgstr "Zusammenstellbares Produkt" msgstr "Zusammenstellbares Produkt"
#: includes/Product_Selector.php, templates/product-selector.twig #: includes/ProductSelector.php, templates/product-selector.twig
msgid "Select Your Products" msgid "Select Your Products"
msgstr "Wähle deine Produkte" msgstr "Wähle deine Produkte"
@@ -111,115 +111,115 @@ msgstr "Gesamtpreis:"
msgid "Add to Cart" msgid "Add to Cart"
msgstr "In den Warenkorb" msgstr "In den Warenkorb"
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Please select at least one product." msgid "Please select at least one product."
msgstr "Bitte wähle mindestens ein Produkt aus." msgstr "Bitte wähle mindestens ein Produkt aus."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "You can select a maximum of %d products." msgid "You can select a maximum of %d products."
msgstr "Du kannst maximal %d Produkte auswählen." msgstr "Du kannst maximal %d Produkte auswählen."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "One or more selected products are not available." msgid "One or more selected products are not available."
msgstr "Ein oder mehrere ausgewählte Produkte sind nicht verfügbar." msgstr "Ein oder mehrere ausgewählte Produkte sind nicht verfügbar."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Selected Products" msgid "Selected Products"
msgstr "Ausgewählte Produkte" msgstr "Ausgewählte Produkte"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Composable Options" msgid "Composable Options"
msgstr "Zusammenstellungsoptionen" msgstr "Zusammenstellungsoptionen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Limit" msgid "Selection Limit"
msgstr "Auswahllimit" msgstr "Auswahllimit"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Maximum number of items customers can select. Leave empty to use global default." msgid "Maximum number of items customers can select. Leave empty to use global default."
msgstr "Maximale Anzahl der Artikel, die Kunden auswählen können. Leer lassen, um den globalen Standard zu verwenden." msgstr "Maximale Anzahl der Artikel, die Kunden auswählen können. Leer lassen, um den globalen Standard zu verwenden."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Pricing Mode" msgid "Pricing Mode"
msgstr "Preismodus" msgstr "Preismodus"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to calculate the price." msgid "How to calculate the price."
msgstr "Wie der Preis berechnet wird." msgstr "Wie der Preis berechnet wird."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Fixed Price" msgid "Fixed Price"
msgstr "Festpreis" msgstr "Festpreis"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter the fixed price for this composable product." msgid "Enter the fixed price for this composable product."
msgstr "Gib den Festpreis für dieses zusammenstellbare Produkt ein." msgstr "Gib den Festpreis für dieses zusammenstellbare Produkt ein."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Use global default" msgid "Use global default"
msgstr "Globalen Standard verwenden" msgstr "Globalen Standard verwenden"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Criteria" msgid "Selection Criteria"
msgstr "Auswahlkriterien" msgstr "Auswahlkriterien"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to select available products." msgid "How to select available products."
msgstr "Wie verfügbare Produkte ausgewählt werden." msgstr "Wie verfügbare Produkte ausgewählt werden."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Category" msgid "By Category"
msgstr "Nach Kategorie" msgstr "Nach Kategorie"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Tag" msgid "By Tag"
msgstr "Nach Schlagwort" msgstr "Nach Schlagwort"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By SKU" msgid "By SKU"
msgstr "Nach Artikelnummer" msgstr "Nach Artikelnummer"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Categories" msgid "Select Categories"
msgstr "Kategorien auswählen" msgstr "Kategorien auswählen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product categories to include." msgid "Select product categories to include."
msgstr "Produktkategorien auswählen, die einbezogen werden sollen." msgstr "Produktkategorien auswählen, die einbezogen werden sollen."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Tags" msgid "Select Tags"
msgstr "Schlagwörter auswählen" msgstr "Schlagwörter auswählen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product tags to include." msgid "Select product tags to include."
msgstr "Produkt-Schlagwörter auswählen, die einbezogen werden sollen." msgstr "Produkt-Schlagwörter auswählen, die einbezogen werden sollen."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Product SKUs" msgid "Product SKUs"
msgstr "Produkt-Artikelnummern" msgstr "Produkt-Artikelnummern"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter product SKUs separated by commas." msgid "Enter product SKUs separated by commas."
msgstr "Gib Produkt-Artikelnummern durch Kommas getrennt ein." msgstr "Gib Produkt-Artikelnummern durch Kommas getrennt ein."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "SKU-1, SKU-2, SKU-3" msgid "SKU-1, SKU-2, SKU-3"
msgstr "ART-1, ART-2, ART-3" msgstr "ART-1, ART-2, ART-3"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "\"%s\" is out of stock and cannot be selected." msgid "\"%s\" is out of stock and cannot be selected."
msgstr "\"%s\" ist nicht an Lager und kann nicht ausgewählt werden." msgstr "\"%s\" ist nicht an Lager und kann nicht ausgewählt werden."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Only %2$d of \"%1$s\" are available in stock." msgid "Only %2$d of \"%1$s\" are available in stock."
msgstr "Nur %2$d von \"%1$s\" sind an Lager verfügbar." msgstr "Nur %2$d von \"%1$s\" sind an Lager verfügbar."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)" msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)"
msgstr "Lagerbestand reduziert für \"%1$s\": -%2$d (verbleibend: %3$d)" msgstr "Lagerbestand reduziert für \"%1$s\": -%2$d (verbleibend: %3$d)"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)" msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)"
msgstr "Lagerbestand wiederhergestellt für \"%1$s\": +%2$d (gesamt: %3$d)" msgstr "Lagerbestand wiederhergestellt für \"%1$s\": +%2$d (gesamt: %3$d)"
@@ -242,3 +242,23 @@ msgstr "An Lager"
#: templates/product-selector.twig #: templates/product-selector.twig
msgid "No products available for selection. Please configure the product criteria in the admin panel." msgid "No products available for selection. Please configure the product criteria in the admin panel."
msgstr "Keine Produkte zur Auswahl verfügbar. Bitte konfiguriere die Produktkriterien im Admin-Bereich." msgstr "Keine Produkte zur Auswahl verfügbar. Bitte konfiguriere die Produktkriterien im Admin-Bereich."
#: includes/Admin/Settings.php, includes/Admin/ProductData.php
msgid "Include Non-Public Products"
msgstr "Nicht-öffentliche Produkte einbeziehen"
#: includes/Admin/Settings.php
msgid "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."
msgstr "Entwürfe und private Produkte in der Auswahl zusammenstellbarer Produkte anzeigen. Nützlich, wenn Produkte nur als Teil einer Zusammenstellung verkauft werden sollen, nicht einzeln."
#: includes/Admin/ProductData.php
msgid "Allow draft and private products in the selection. Useful when products should only be sold as part of a composition."
msgstr "Entwürfe und private Produkte in der Auswahl zulassen. Nützlich, wenn Produkte nur als Teil einer Zusammenstellung verkauft werden sollen."
#: includes/Admin/ProductData.php
msgid "Yes"
msgstr "Ja"
#: includes/Admin/ProductData.php
msgid "No"
msgstr "Nein"

View File

@@ -91,7 +91,7 @@ msgstr "Den Gesamtpreis anzeigen, während Kunden Auswahlen treffen."
msgid "Composable product" msgid "Composable product"
msgstr "Zusammenstellbares Produkt" msgstr "Zusammenstellbares Produkt"
#: includes/Product_Selector.php, templates/product-selector.twig #: includes/ProductSelector.php, templates/product-selector.twig
msgid "Select Your Products" msgid "Select Your Products"
msgstr "Wählen Sie Ihre Produkte" msgstr "Wählen Sie Ihre Produkte"
@@ -111,115 +111,115 @@ msgstr "Gesamtpreis:"
msgid "Add to Cart" msgid "Add to Cart"
msgstr "In den Warenkorb" msgstr "In den Warenkorb"
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Please select at least one product." msgid "Please select at least one product."
msgstr "Bitte wählen Sie mindestens ein Produkt aus." msgstr "Bitte wählen Sie mindestens ein Produkt aus."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "You can select a maximum of %d products." msgid "You can select a maximum of %d products."
msgstr "Sie können maximal %d Produkte auswählen." msgstr "Sie können maximal %d Produkte auswählen."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "One or more selected products are not available." msgid "One or more selected products are not available."
msgstr "Ein oder mehrere ausgewählte Produkte sind nicht verfügbar." msgstr "Ein oder mehrere ausgewählte Produkte sind nicht verfügbar."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Selected Products" msgid "Selected Products"
msgstr "Ausgewählte Produkte" msgstr "Ausgewählte Produkte"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Composable Options" msgid "Composable Options"
msgstr "Zusammenstellungsoptionen" msgstr "Zusammenstellungsoptionen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Limit" msgid "Selection Limit"
msgstr "Auswahllimit" msgstr "Auswahllimit"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Maximum number of items customers can select. Leave empty to use global default." msgid "Maximum number of items customers can select. Leave empty to use global default."
msgstr "Maximale Anzahl der Artikel, die Kunden auswählen können. Leer lassen, um den globalen Standard zu verwenden." msgstr "Maximale Anzahl der Artikel, die Kunden auswählen können. Leer lassen, um den globalen Standard zu verwenden."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Pricing Mode" msgid "Pricing Mode"
msgstr "Preismodus" msgstr "Preismodus"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to calculate the price." msgid "How to calculate the price."
msgstr "Wie der Preis berechnet wird." msgstr "Wie der Preis berechnet wird."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Fixed Price" msgid "Fixed Price"
msgstr "Festpreis" msgstr "Festpreis"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter the fixed price for this composable product." msgid "Enter the fixed price for this composable product."
msgstr "Geben Sie den Festpreis für dieses zusammenstellbare Produkt ein." msgstr "Geben Sie den Festpreis für dieses zusammenstellbare Produkt ein."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Use global default" msgid "Use global default"
msgstr "Globalen Standard verwenden" msgstr "Globalen Standard verwenden"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Criteria" msgid "Selection Criteria"
msgstr "Auswahlkriterien" msgstr "Auswahlkriterien"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to select available products." msgid "How to select available products."
msgstr "Wie verfügbare Produkte ausgewählt werden." msgstr "Wie verfügbare Produkte ausgewählt werden."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Category" msgid "By Category"
msgstr "Nach Kategorie" msgstr "Nach Kategorie"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Tag" msgid "By Tag"
msgstr "Nach Schlagwort" msgstr "Nach Schlagwort"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By SKU" msgid "By SKU"
msgstr "Nach Artikelnummer" msgstr "Nach Artikelnummer"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Categories" msgid "Select Categories"
msgstr "Kategorien auswählen" msgstr "Kategorien auswählen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product categories to include." msgid "Select product categories to include."
msgstr "Produktkategorien auswählen, die einbezogen werden sollen." msgstr "Produktkategorien auswählen, die einbezogen werden sollen."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Tags" msgid "Select Tags"
msgstr "Schlagwörter auswählen" msgstr "Schlagwörter auswählen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product tags to include." msgid "Select product tags to include."
msgstr "Produkt-Schlagwörter auswählen, die einbezogen werden sollen." msgstr "Produkt-Schlagwörter auswählen, die einbezogen werden sollen."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Product SKUs" msgid "Product SKUs"
msgstr "Produkt-Artikelnummern" msgstr "Produkt-Artikelnummern"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter product SKUs separated by commas." msgid "Enter product SKUs separated by commas."
msgstr "Geben Sie Produkt-Artikelnummern durch Kommas getrennt ein." msgstr "Geben Sie Produkt-Artikelnummern durch Kommas getrennt ein."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "SKU-1, SKU-2, SKU-3" msgid "SKU-1, SKU-2, SKU-3"
msgstr "ART-1, ART-2, ART-3" msgstr "ART-1, ART-2, ART-3"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "\"%s\" is out of stock and cannot be selected." msgid "\"%s\" is out of stock and cannot be selected."
msgstr "\"%s\" ist nicht auf Lager und kann nicht ausgewählt werden." msgstr "\"%s\" ist nicht auf Lager und kann nicht ausgewählt werden."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Only %2$d of \"%1$s\" are available in stock." msgid "Only %2$d of \"%1$s\" are available in stock."
msgstr "Nur %2$d von \"%1$s\" sind auf Lager verfügbar." msgstr "Nur %2$d von \"%1$s\" sind auf Lager verfügbar."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)" msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)"
msgstr "Lagerbestand reduziert für \"%1$s\": -%2$d (verbleibend: %3$d)" msgstr "Lagerbestand reduziert für \"%1$s\": -%2$d (verbleibend: %3$d)"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)" msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)"
msgstr "Lagerbestand wiederhergestellt für \"%1$s\": +%2$d (gesamt: %3$d)" msgstr "Lagerbestand wiederhergestellt für \"%1$s\": +%2$d (gesamt: %3$d)"
@@ -242,3 +242,23 @@ msgstr "Auf Lager"
#: templates/product-selector.twig #: templates/product-selector.twig
msgid "No products available for selection. Please configure the product criteria in the admin panel." msgid "No products available for selection. Please configure the product criteria in the admin panel."
msgstr "Keine Produkte zur Auswahl verfügbar. Bitte konfigurieren Sie die Produktkriterien im Admin-Bereich." msgstr "Keine Produkte zur Auswahl verfügbar. Bitte konfigurieren Sie die Produktkriterien im Admin-Bereich."
#: includes/Admin/Settings.php, includes/Admin/ProductData.php
msgid "Include Non-Public Products"
msgstr "Nicht-öffentliche Produkte einbeziehen"
#: includes/Admin/Settings.php
msgid "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."
msgstr "Entwürfe und private Produkte in der Auswahl zusammenstellbarer Produkte anzeigen. Nützlich, wenn Produkte nur als Teil einer Zusammenstellung verkauft werden sollen, nicht einzeln."
#: includes/Admin/ProductData.php
msgid "Allow draft and private products in the selection. Useful when products should only be sold as part of a composition."
msgstr "Entwürfe und private Produkte in der Auswahl zulassen. Nützlich, wenn Produkte nur als Teil einer Zusammenstellung verkauft werden sollen."
#: includes/Admin/ProductData.php
msgid "Yes"
msgstr "Ja"
#: includes/Admin/ProductData.php
msgid "No"
msgstr "Nein"

View File

@@ -91,7 +91,7 @@ msgstr "Den Gesamtpreis anzeigen, während Kunden Auswahlen treffen."
msgid "Composable product" msgid "Composable product"
msgstr "Zusammenstellbares Produkt" msgstr "Zusammenstellbares Produkt"
#: includes/Product_Selector.php, templates/product-selector.twig #: includes/ProductSelector.php, templates/product-selector.twig
msgid "Select Your Products" msgid "Select Your Products"
msgstr "Wähle deine Produkte" msgstr "Wähle deine Produkte"
@@ -111,115 +111,115 @@ msgstr "Gesamtpreis:"
msgid "Add to Cart" msgid "Add to Cart"
msgstr "In den Warenkorb" msgstr "In den Warenkorb"
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Please select at least one product." msgid "Please select at least one product."
msgstr "Bitte wähle mindestens ein Produkt aus." msgstr "Bitte wähle mindestens ein Produkt aus."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "You can select a maximum of %d products." msgid "You can select a maximum of %d products."
msgstr "Du kannst maximal %d Produkte auswählen." msgstr "Du kannst maximal %d Produkte auswählen."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "One or more selected products are not available." msgid "One or more selected products are not available."
msgstr "Ein oder mehrere ausgewählte Produkte sind nicht verfügbar." msgstr "Ein oder mehrere ausgewählte Produkte sind nicht verfügbar."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Selected Products" msgid "Selected Products"
msgstr "Ausgewählte Produkte" msgstr "Ausgewählte Produkte"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Composable Options" msgid "Composable Options"
msgstr "Zusammenstellungsoptionen" msgstr "Zusammenstellungsoptionen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Limit" msgid "Selection Limit"
msgstr "Auswahllimit" msgstr "Auswahllimit"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Maximum number of items customers can select. Leave empty to use global default." msgid "Maximum number of items customers can select. Leave empty to use global default."
msgstr "Maximale Anzahl der Artikel, die Kunden auswählen können. Leer lassen, um den globalen Standard zu verwenden." msgstr "Maximale Anzahl der Artikel, die Kunden auswählen können. Leer lassen, um den globalen Standard zu verwenden."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Pricing Mode" msgid "Pricing Mode"
msgstr "Preismodus" msgstr "Preismodus"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to calculate the price." msgid "How to calculate the price."
msgstr "Wie der Preis berechnet wird." msgstr "Wie der Preis berechnet wird."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Fixed Price" msgid "Fixed Price"
msgstr "Festpreis" msgstr "Festpreis"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter the fixed price for this composable product." msgid "Enter the fixed price for this composable product."
msgstr "Gib den Festpreis für dieses zusammenstellbare Produkt ein." msgstr "Gib den Festpreis für dieses zusammenstellbare Produkt ein."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Use global default" msgid "Use global default"
msgstr "Globalen Standard verwenden" msgstr "Globalen Standard verwenden"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Criteria" msgid "Selection Criteria"
msgstr "Auswahlkriterien" msgstr "Auswahlkriterien"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to select available products." msgid "How to select available products."
msgstr "Wie verfügbare Produkte ausgewählt werden." msgstr "Wie verfügbare Produkte ausgewählt werden."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Category" msgid "By Category"
msgstr "Nach Kategorie" msgstr "Nach Kategorie"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Tag" msgid "By Tag"
msgstr "Nach Schlagwort" msgstr "Nach Schlagwort"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By SKU" msgid "By SKU"
msgstr "Nach Artikelnummer" msgstr "Nach Artikelnummer"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Categories" msgid "Select Categories"
msgstr "Kategorien auswählen" msgstr "Kategorien auswählen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product categories to include." msgid "Select product categories to include."
msgstr "Produktkategorien auswählen, die einbezogen werden sollen." msgstr "Produktkategorien auswählen, die einbezogen werden sollen."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Tags" msgid "Select Tags"
msgstr "Schlagwörter auswählen" msgstr "Schlagwörter auswählen"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product tags to include." msgid "Select product tags to include."
msgstr "Produkt-Schlagwörter auswählen, die einbezogen werden sollen." msgstr "Produkt-Schlagwörter auswählen, die einbezogen werden sollen."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Product SKUs" msgid "Product SKUs"
msgstr "Produkt-Artikelnummern" msgstr "Produkt-Artikelnummern"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter product SKUs separated by commas." msgid "Enter product SKUs separated by commas."
msgstr "Gib Produkt-Artikelnummern durch Kommas getrennt ein." msgstr "Gib Produkt-Artikelnummern durch Kommas getrennt ein."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "SKU-1, SKU-2, SKU-3" msgid "SKU-1, SKU-2, SKU-3"
msgstr "ART-1, ART-2, ART-3" msgstr "ART-1, ART-2, ART-3"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "\"%s\" is out of stock and cannot be selected." msgid "\"%s\" is out of stock and cannot be selected."
msgstr "\"%s\" ist nicht auf Lager und kann nicht ausgewählt werden." msgstr "\"%s\" ist nicht auf Lager und kann nicht ausgewählt werden."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Only %2$d of \"%1$s\" are available in stock." msgid "Only %2$d of \"%1$s\" are available in stock."
msgstr "Nur %2$d von \"%1$s\" sind auf Lager verfügbar." msgstr "Nur %2$d von \"%1$s\" sind auf Lager verfügbar."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)" msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)"
msgstr "Lagerbestand reduziert für \"%1$s\": -%2$d (verbleibend: %3$d)" msgstr "Lagerbestand reduziert für \"%1$s\": -%2$d (verbleibend: %3$d)"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)" msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)"
msgstr "Lagerbestand wiederhergestellt für \"%1$s\": +%2$d (gesamt: %3$d)" msgstr "Lagerbestand wiederhergestellt für \"%1$s\": +%2$d (gesamt: %3$d)"
@@ -242,3 +242,23 @@ msgstr "Auf Lager"
#: templates/product-selector.twig #: templates/product-selector.twig
msgid "No products available for selection. Please configure the product criteria in the admin panel." msgid "No products available for selection. Please configure the product criteria in the admin panel."
msgstr "Keine Produkte zur Auswahl verfügbar. Bitte konfiguriere die Produktkriterien im Admin-Bereich." msgstr "Keine Produkte zur Auswahl verfügbar. Bitte konfiguriere die Produktkriterien im Admin-Bereich."
#: includes/Admin/Settings.php, includes/Admin/ProductData.php
msgid "Include Non-Public Products"
msgstr "Nicht-öffentliche Produkte einbeziehen"
#: includes/Admin/Settings.php
msgid "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."
msgstr "Entwürfe und private Produkte in der Auswahl zusammenstellbarer Produkte anzeigen. Nützlich, wenn Produkte nur als Teil einer Zusammenstellung verkauft werden sollen, nicht einzeln."
#: includes/Admin/ProductData.php
msgid "Allow draft and private products in the selection. Useful when products should only be sold as part of a composition."
msgstr "Entwürfe und private Produkte in der Auswahl zulassen. Nützlich, wenn Produkte nur als Teil einer Zusammenstellung verkauft werden sollen."
#: includes/Admin/ProductData.php
msgid "Yes"
msgstr "Ja"
#: includes/Admin/ProductData.php
msgid "No"
msgstr "Nein"

View File

@@ -91,7 +91,7 @@ msgstr "Afficher le prix total pendant que les clients font leurs sélections."
msgid "Composable product" msgid "Composable product"
msgstr "Produit composable" msgstr "Produit composable"
#: includes/Product_Selector.php, templates/product-selector.twig #: includes/ProductSelector.php, templates/product-selector.twig
msgid "Select Your Products" msgid "Select Your Products"
msgstr "Sélectionnez vos produits" msgstr "Sélectionnez vos produits"
@@ -111,115 +111,115 @@ msgstr "Prix total :"
msgid "Add to Cart" msgid "Add to Cart"
msgstr "Ajouter au panier" msgstr "Ajouter au panier"
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Please select at least one product." msgid "Please select at least one product."
msgstr "Veuillez sélectionner au moins un produit." msgstr "Veuillez sélectionner au moins un produit."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "You can select a maximum of %d products." msgid "You can select a maximum of %d products."
msgstr "Vous pouvez sélectionner un maximum de %d produits." msgstr "Vous pouvez sélectionner un maximum de %d produits."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "One or more selected products are not available." msgid "One or more selected products are not available."
msgstr "Un ou plusieurs produits sélectionnés ne sont pas disponibles." msgstr "Un ou plusieurs produits sélectionnés ne sont pas disponibles."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Selected Products" msgid "Selected Products"
msgstr "Produits sélectionnés" msgstr "Produits sélectionnés"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Composable Options" msgid "Composable Options"
msgstr "Options de composition" msgstr "Options de composition"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Limit" msgid "Selection Limit"
msgstr "Limite de sélection" msgstr "Limite de sélection"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Maximum number of items customers can select. Leave empty to use global default." msgid "Maximum number of items customers can select. Leave empty to use global default."
msgstr "Nombre maximum d'articles que les clients peuvent sélectionner. Laisser vide pour utiliser la valeur par défaut globale." msgstr "Nombre maximum d'articles que les clients peuvent sélectionner. Laisser vide pour utiliser la valeur par défaut globale."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Pricing Mode" msgid "Pricing Mode"
msgstr "Mode de tarification" msgstr "Mode de tarification"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to calculate the price." msgid "How to calculate the price."
msgstr "Comment calculer le prix." msgstr "Comment calculer le prix."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Fixed Price" msgid "Fixed Price"
msgstr "Prix fixe" msgstr "Prix fixe"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter the fixed price for this composable product." msgid "Enter the fixed price for this composable product."
msgstr "Entrez le prix fixe pour ce produit composable." msgstr "Entrez le prix fixe pour ce produit composable."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Use global default" msgid "Use global default"
msgstr "Utiliser la valeur par défaut globale" msgstr "Utiliser la valeur par défaut globale"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Criteria" msgid "Selection Criteria"
msgstr "Critères de sélection" msgstr "Critères de sélection"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to select available products." msgid "How to select available products."
msgstr "Comment sélectionner les produits disponibles." msgstr "Comment sélectionner les produits disponibles."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Category" msgid "By Category"
msgstr "Par catégorie" msgstr "Par catégorie"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Tag" msgid "By Tag"
msgstr "Par étiquette" msgstr "Par étiquette"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By SKU" msgid "By SKU"
msgstr "Par référence" msgstr "Par référence"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Categories" msgid "Select Categories"
msgstr "Sélectionner les catégories" msgstr "Sélectionner les catégories"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product categories to include." msgid "Select product categories to include."
msgstr "Sélectionner les catégories de produits à inclure." msgstr "Sélectionner les catégories de produits à inclure."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Tags" msgid "Select Tags"
msgstr "Sélectionner les étiquettes" msgstr "Sélectionner les étiquettes"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product tags to include." msgid "Select product tags to include."
msgstr "Sélectionner les étiquettes de produits à inclure." msgstr "Sélectionner les étiquettes de produits à inclure."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Product SKUs" msgid "Product SKUs"
msgstr "Références des produits" msgstr "Références des produits"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter product SKUs separated by commas." msgid "Enter product SKUs separated by commas."
msgstr "Entrez les références des produits séparées par des virgules." msgstr "Entrez les références des produits séparées par des virgules."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "SKU-1, SKU-2, SKU-3" msgid "SKU-1, SKU-2, SKU-3"
msgstr "REF-1, REF-2, REF-3" msgstr "REF-1, REF-2, REF-3"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "\"%s\" is out of stock and cannot be selected." msgid "\"%s\" is out of stock and cannot be selected."
msgstr "\"%s\" est en rupture de stock et ne peut pas être sélectionné." msgstr "\"%s\" est en rupture de stock et ne peut pas être sélectionné."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Only %2$d of \"%1$s\" are available in stock." msgid "Only %2$d of \"%1$s\" are available in stock."
msgstr "Seulement %2$d de \"%1$s\" sont disponibles en stock." msgstr "Seulement %2$d de \"%1$s\" sont disponibles en stock."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)" msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)"
msgstr "Stock réduit pour \"%1$s\": -%2$d (restant: %3$d)" msgstr "Stock réduit pour \"%1$s\": -%2$d (restant: %3$d)"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)" msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)"
msgstr "Stock restauré pour \"%1$s\": +%2$d (total: %3$d)" msgstr "Stock restauré pour \"%1$s\": +%2$d (total: %3$d)"
@@ -242,3 +242,23 @@ msgstr "En stock"
#: templates/product-selector.twig #: templates/product-selector.twig
msgid "No products available for selection. Please configure the product criteria in the admin panel." msgid "No products available for selection. Please configure the product criteria in the admin panel."
msgstr "Aucun produit disponible pour la sélection. Veuillez configurer les critères de produit dans le panneau d'administration." msgstr "Aucun produit disponible pour la sélection. Veuillez configurer les critères de produit dans le panneau d'administration."
#: includes/Admin/Settings.php, includes/Admin/ProductData.php
msgid "Include Non-Public Products"
msgstr "Inclure les produits non publics"
#: includes/Admin/Settings.php
msgid "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."
msgstr "Autoriser les brouillons et les produits privés dans les sélections de produits composables. Utile lorsque les produits ne doivent être vendus que dans le cadre d'une composition, pas individuellement."
#: includes/Admin/ProductData.php
msgid "Allow draft and private products in the selection. Useful when products should only be sold as part of a composition."
msgstr "Autoriser les brouillons et les produits privés dans la sélection. Utile lorsque les produits ne doivent être vendus que dans le cadre d'une composition."
#: includes/Admin/ProductData.php
msgid "Yes"
msgstr "Oui"
#: includes/Admin/ProductData.php
msgid "No"
msgstr "Non"

View File

@@ -91,7 +91,7 @@ msgstr "Visualizzare il prezzo totale mentre i clienti effettuano le selezioni."
msgid "Composable product" msgid "Composable product"
msgstr "Prodotto componibile" msgstr "Prodotto componibile"
#: includes/Product_Selector.php, templates/product-selector.twig #: includes/ProductSelector.php, templates/product-selector.twig
msgid "Select Your Products" msgid "Select Your Products"
msgstr "Seleziona i tuoi prodotti" msgstr "Seleziona i tuoi prodotti"
@@ -111,115 +111,115 @@ msgstr "Prezzo totale:"
msgid "Add to Cart" msgid "Add to Cart"
msgstr "Aggiungi al carrello" msgstr "Aggiungi al carrello"
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Please select at least one product." msgid "Please select at least one product."
msgstr "Seleziona almeno un prodotto." msgstr "Seleziona almeno un prodotto."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "You can select a maximum of %d products." msgid "You can select a maximum of %d products."
msgstr "Puoi selezionare un massimo di %d prodotti." msgstr "Puoi selezionare un massimo di %d prodotti."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "One or more selected products are not available." msgid "One or more selected products are not available."
msgstr "Uno o più prodotti selezionati non sono disponibili." msgstr "Uno o più prodotti selezionati non sono disponibili."
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Selected Products" msgid "Selected Products"
msgstr "Prodotti selezionati" msgstr "Prodotti selezionati"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Composable Options" msgid "Composable Options"
msgstr "Opzioni di composizione" msgstr "Opzioni di composizione"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Limit" msgid "Selection Limit"
msgstr "Limite di selezione" msgstr "Limite di selezione"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Maximum number of items customers can select. Leave empty to use global default." msgid "Maximum number of items customers can select. Leave empty to use global default."
msgstr "Numero massimo di articoli che i clienti possono selezionare. Lasciare vuoto per utilizzare il valore predefinito globale." msgstr "Numero massimo di articoli che i clienti possono selezionare. Lasciare vuoto per utilizzare il valore predefinito globale."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Pricing Mode" msgid "Pricing Mode"
msgstr "Modalità di prezzo" msgstr "Modalità di prezzo"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to calculate the price." msgid "How to calculate the price."
msgstr "Come calcolare il prezzo." msgstr "Come calcolare il prezzo."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Fixed Price" msgid "Fixed Price"
msgstr "Prezzo fisso" msgstr "Prezzo fisso"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter the fixed price for this composable product." msgid "Enter the fixed price for this composable product."
msgstr "Inserisci il prezzo fisso per questo prodotto componibile." msgstr "Inserisci il prezzo fisso per questo prodotto componibile."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Use global default" msgid "Use global default"
msgstr "Usa predefinito globale" msgstr "Usa predefinito globale"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Criteria" msgid "Selection Criteria"
msgstr "Criteri di selezione" msgstr "Criteri di selezione"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to select available products." msgid "How to select available products."
msgstr "Come selezionare i prodotti disponibili." msgstr "Come selezionare i prodotti disponibili."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Category" msgid "By Category"
msgstr "Per categoria" msgstr "Per categoria"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Tag" msgid "By Tag"
msgstr "Per etichetta" msgstr "Per etichetta"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By SKU" msgid "By SKU"
msgstr "Per codice articolo" msgstr "Per codice articolo"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Categories" msgid "Select Categories"
msgstr "Seleziona categorie" msgstr "Seleziona categorie"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product categories to include." msgid "Select product categories to include."
msgstr "Selezionare le categorie di prodotti da includere." msgstr "Selezionare le categorie di prodotti da includere."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Tags" msgid "Select Tags"
msgstr "Seleziona etichette" msgstr "Seleziona etichette"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product tags to include." msgid "Select product tags to include."
msgstr "Selezionare le etichette dei prodotti da includere." msgstr "Selezionare le etichette dei prodotti da includere."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Product SKUs" msgid "Product SKUs"
msgstr "Codici articolo prodotti" msgstr "Codici articolo prodotti"
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter product SKUs separated by commas." msgid "Enter product SKUs separated by commas."
msgstr "Inserire i codici articolo dei prodotti separati da virgole." msgstr "Inserire i codici articolo dei prodotti separati da virgole."
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "SKU-1, SKU-2, SKU-3" msgid "SKU-1, SKU-2, SKU-3"
msgstr "COD-1, COD-2, COD-3" msgstr "COD-1, COD-2, COD-3"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "\"%s\" is out of stock and cannot be selected." msgid "\"%s\" is out of stock and cannot be selected."
msgstr "\"%s\" è esaurito e non può essere selezionato." msgstr "\"%s\" è esaurito e non può essere selezionato."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Only %2$d of \"%1$s\" are available in stock." msgid "Only %2$d of \"%1$s\" are available in stock."
msgstr "Solo %2$d di \"%1$s\" sono disponibili in magazzino." msgstr "Solo %2$d di \"%1$s\" sono disponibili in magazzino."
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)" msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)"
msgstr "Giacenza ridotta per \"%1$s\": -%2$d (rimanenti: %3$d)" msgstr "Giacenza ridotta per \"%1$s\": -%2$d (rimanenti: %3$d)"
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)" msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)"
msgstr "Giacenza ripristinata per \"%1$s\": +%2$d (totale: %3$d)" msgstr "Giacenza ripristinata per \"%1$s\": +%2$d (totale: %3$d)"
@@ -242,3 +242,23 @@ msgstr "Disponibile"
#: templates/product-selector.twig #: templates/product-selector.twig
msgid "No products available for selection. Please configure the product criteria in the admin panel." msgid "No products available for selection. Please configure the product criteria in the admin panel."
msgstr "Nessun prodotto disponibile per la selezione. Si prega di configurare i criteri del prodotto nel pannello di amministrazione." msgstr "Nessun prodotto disponibile per la selezione. Si prega di configurare i criteri del prodotto nel pannello di amministrazione."
#: includes/Admin/Settings.php, includes/Admin/ProductData.php
msgid "Include Non-Public Products"
msgstr "Includi prodotti non pubblici"
#: includes/Admin/Settings.php
msgid "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."
msgstr "Consenti la visualizzazione di bozze e prodotti privati nelle selezioni dei prodotti componibili. Utile quando i prodotti devono essere venduti solo come parte di una composizione, non singolarmente."
#: includes/Admin/ProductData.php
msgid "Allow draft and private products in the selection. Useful when products should only be sold as part of a composition."
msgstr "Consenti bozze e prodotti privati nella selezione. Utile quando i prodotti devono essere venduti solo come parte di una composizione."
#: includes/Admin/ProductData.php
msgid "Yes"
msgstr "Sì"
#: includes/Admin/ProductData.php
msgid "No"
msgstr "No"

View File

@@ -90,7 +90,7 @@ msgstr ""
msgid "Composable product" msgid "Composable product"
msgstr "" msgstr ""
#: includes/Product_Selector.php, templates/product-selector.twig #: includes/ProductSelector.php, templates/product-selector.twig
msgid "Select Your Products" msgid "Select Your Products"
msgstr "" msgstr ""
@@ -110,115 +110,115 @@ msgstr ""
msgid "Add to Cart" msgid "Add to Cart"
msgstr "" msgstr ""
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Please select at least one product." msgid "Please select at least one product."
msgstr "" msgstr ""
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "You can select a maximum of %d products." msgid "You can select a maximum of %d products."
msgstr "" msgstr ""
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "One or more selected products are not available." msgid "One or more selected products are not available."
msgstr "" msgstr ""
#: includes/Cart_Handler.php #: includes/CartHandler.php
msgid "Selected Products" msgid "Selected Products"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Composable Options" msgid "Composable Options"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Limit" msgid "Selection Limit"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Maximum number of items customers can select. Leave empty to use global default." msgid "Maximum number of items customers can select. Leave empty to use global default."
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Pricing Mode" msgid "Pricing Mode"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to calculate the price." msgid "How to calculate the price."
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Fixed Price" msgid "Fixed Price"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter the fixed price for this composable product." msgid "Enter the fixed price for this composable product."
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Use global default" msgid "Use global default"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Selection Criteria" msgid "Selection Criteria"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "How to select available products." msgid "How to select available products."
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Category" msgid "By Category"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By Tag" msgid "By Tag"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "By SKU" msgid "By SKU"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Categories" msgid "Select Categories"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product categories to include." msgid "Select product categories to include."
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select Tags" msgid "Select Tags"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Select product tags to include." msgid "Select product tags to include."
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Product SKUs" msgid "Product SKUs"
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "Enter product SKUs separated by commas." msgid "Enter product SKUs separated by commas."
msgstr "" msgstr ""
#: includes/Admin/Product_Data.php #: includes/Admin/ProductData.php
msgid "SKU-1, SKU-2, SKU-3" msgid "SKU-1, SKU-2, SKU-3"
msgstr "" msgstr ""
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "\"%s\" is out of stock and cannot be selected." msgid "\"%s\" is out of stock and cannot be selected."
msgstr "" msgstr ""
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Only %2$d of \"%1$s\" are available in stock." msgid "Only %2$d of \"%1$s\" are available in stock."
msgstr "" msgstr ""
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)" msgid "Stock reduced for \"%1$s\": -%2$d (remaining: %3$d)"
msgstr "" msgstr ""
#: includes/Stock_Manager.php #: includes/StockManager.php
msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)" msgid "Stock restored for \"%1$s\": +%2$d (total: %3$d)"
msgstr "" msgstr ""
@@ -241,3 +241,23 @@ msgstr ""
#: templates/product-selector.twig #: templates/product-selector.twig
msgid "No products available for selection. Please configure the product criteria in the admin panel." msgid "No products available for selection. Please configure the product criteria in the admin panel."
msgstr "" msgstr ""
#: includes/Admin/Settings.php, includes/Admin/ProductData.php
msgid "Include Non-Public Products"
msgstr ""
#: includes/Admin/Settings.php
msgid "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."
msgstr ""
#: includes/Admin/ProductData.php
msgid "Allow draft and private products in the selection. Useful when products should only be sold as part of a composition."
msgstr ""
#: includes/Admin/ProductData.php
msgid "Yes"
msgstr ""
#: includes/Admin/ProductData.php
msgid "No"
msgstr ""

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>

21
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
failOnWarning="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">includes</directory>
</include>
</source>
</phpunit>

75
tests/TestCase.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
/**
* Base Test Case
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Brain\Monkey;
/**
* Base test case with Brain Monkey and Mockery integration.
*
* All test classes should extend this instead of PHPUnit\Framework\TestCase.
*/
abstract class TestCase extends PHPUnitTestCase
{
use MockeryPHPUnitIntegration;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// Stub common WordPress translation functions (__(), _e(), esc_html__(), etc.)
Monkey\Functions\stubTranslationFunctions();
// Stub common WordPress escaping functions (esc_html(), esc_attr(), etc.)
Monkey\Functions\stubEscapeFunctions();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
/**
* Create a Mockery mock of WC_Product with sensible defaults.
*
* @param array $overrides Method return value overrides
* @return \Mockery\MockInterface
*/
protected function createProductMock(array $overrides = []): \Mockery\MockInterface
{
$defaults = [
'get_id' => 100,
'get_name' => 'Test Product',
'get_type' => 'simple',
'get_price' => '10.00',
'get_regular_price' => '10.00',
'get_price_html' => '<span>$10.00</span>',
'get_permalink' => 'https://example.com/product/test',
'get_image_id' => 1,
'get_stock_quantity' => null,
'get_stock_status' => 'instock',
'is_purchasable' => true,
'is_in_stock' => true,
'managing_stock' => false,
'backorders_allowed' => false,
];
$config = array_merge($defaults, $overrides);
$mock = \Mockery::mock('WC_Product');
foreach ($config as $method => $return) {
$mock->shouldReceive($method)->andReturn($return)->byDefault();
}
return $mock;
}
}

View File

@@ -0,0 +1,108 @@
<?php
/**
* Admin ProductData Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit\Admin;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\Admin\ProductData;
use Brain\Monkey\Functions;
class ProductDataTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$_POST = [];
}
protected function tearDown(): void
{
$_POST = [];
parent::tearDown();
}
public function testConstructor_RegistersExpectedHooks(): void
{
$productData = new ProductData();
self::assertNotFalse(has_filter('woocommerce_product_data_tabs', 'Magdev\WcComposableProduct\Admin\ProductData->add_product_data_tab()'));
self::assertNotFalse(has_action('woocommerce_product_data_panels', 'Magdev\WcComposableProduct\Admin\ProductData->add_product_data_panel()'));
self::assertNotFalse(has_action('woocommerce_process_product_meta_composable', 'Magdev\WcComposableProduct\Admin\ProductData->save_product_data()'));
self::assertNotFalse(has_action('woocommerce_product_options_general_product_data', 'Magdev\WcComposableProduct\Admin\ProductData->add_general_fields()'));
}
public function testAddProductDataTab_AddsComposableTab(): void
{
$productData = new ProductData();
$tabs = $productData->add_product_data_tab([]);
$this->assertArrayHasKey('composable', $tabs);
$this->assertSame('composable_product_data', $tabs['composable']['target']);
$this->assertContains('show_if_composable', $tabs['composable']['class']);
$this->assertSame(21, $tabs['composable']['priority']);
}
public function testSaveProductData_SavesAllFields(): void
{
$_POST = [
'_composable_selection_limit' => '5',
'_composable_pricing_mode' => 'fixed',
'_composable_include_unpublished' => 'yes',
'_composable_criteria_type' => 'tag',
'_composable_categories' => ['1', '2'],
'_composable_tags' => ['3', '4'],
'_composable_skus' => 'SKU-1, SKU-2',
];
Functions\expect('absint')->andReturnUsing(function ($val) {
return abs((int) $val);
});
Functions\expect('sanitize_text_field')->andReturnUsing(function ($val) {
return $val;
});
Functions\expect('sanitize_textarea_field')->andReturnUsing(function ($val) {
return $val;
});
Functions\expect('update_post_meta')->times(7);
$productData = new ProductData();
$productData->save_product_data(42);
}
public function testSaveProductData_DefaultsWhenPostEmpty(): void
{
// No POST data at all
Functions\expect('absint')->andReturnUsing(function ($val) {
return abs((int) $val);
});
Functions\expect('sanitize_text_field')->andReturnUsing(function ($val) {
return $val;
});
Functions\expect('sanitize_textarea_field')->andReturnUsing(function ($val) {
return $val;
});
Functions\expect('update_post_meta')
->with(42, '_composable_selection_limit', \Mockery::any())->once();
Functions\expect('update_post_meta')
->with(42, '_composable_pricing_mode', '')->once();
Functions\expect('update_post_meta')
->with(42, '_composable_include_unpublished', '')->once();
Functions\expect('update_post_meta')
->with(42, '_composable_criteria_type', 'category')->once();
Functions\expect('update_post_meta')
->with(42, '_composable_categories', [])->once();
Functions\expect('update_post_meta')
->with(42, '_composable_tags', [])->once();
Functions\expect('update_post_meta')
->with(42, '_composable_skus', '')->once();
$productData = new ProductData();
$productData->save_product_data(42);
}
}

View File

@@ -0,0 +1,84 @@
<?php
/**
* Admin Settings Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit\Admin;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\Admin\Settings;
use Brain\Monkey\Functions;
class SettingsTest extends TestCase
{
public function testConstructor_SetsIdAndLabel(): void
{
$settings = new Settings();
$this->assertSame('composable_products', $settings->get_id());
}
public function testGetSettings_ReturnsExpectedFieldIds(): void
{
Functions\expect('apply_filters')
->once()
->with('wc_composable_settings', \Mockery::type('array'))
->andReturnUsing(function ($hook, $settings) {
return $settings;
});
$settings = new Settings();
$fields = $settings->get_settings();
// Extract all field IDs
$ids = array_column($fields, 'id');
$this->assertContains('wc_composable_settings', $ids);
$this->assertContains('wc_composable_default_limit', $ids);
$this->assertContains('wc_composable_default_pricing', $ids);
$this->assertContains('wc_composable_include_unpublished', $ids);
$this->assertContains('wc_composable_show_images', $ids);
$this->assertContains('wc_composable_show_prices', $ids);
$this->assertContains('wc_composable_show_total', $ids);
}
public function testGetSettings_HasCorrectFieldTypes(): void
{
Functions\expect('apply_filters')
->once()
->andReturnUsing(function ($hook, $settings) {
return $settings;
});
$settings = new Settings();
$fields = $settings->get_settings();
// Index fields by ID for easy lookup
$indexed = [];
foreach ($fields as $field) {
if (isset($field['id'])) {
$indexed[$field['id']] = $field;
}
}
$this->assertSame('number', $indexed['wc_composable_default_limit']['type']);
$this->assertSame('select', $indexed['wc_composable_default_pricing']['type']);
$this->assertSame('checkbox', $indexed['wc_composable_include_unpublished']['type']);
$this->assertSame('checkbox', $indexed['wc_composable_show_images']['type']);
}
public function testGetSettings_AppliesFilter(): void
{
Functions\expect('apply_filters')
->once()
->with('wc_composable_settings', \Mockery::type('array'))
->andReturnUsing(function ($hook, $settings) {
return $settings;
});
$settings = new Settings();
$settings->get_settings();
}
}

View File

@@ -0,0 +1,184 @@
<?php
/**
* CartHandler Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\CartHandler;
use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
class CartHandlerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Clean up POST superglobal between tests
$_POST = [];
}
protected function tearDown(): void
{
$_POST = [];
parent::tearDown();
}
public function testConstructor_RegistersExpectedHooks(): void
{
$handler = new CartHandler();
self::assertNotFalse(has_filter('woocommerce_add_to_cart_validation', 'Magdev\WcComposableProduct\CartHandler->validate_add_to_cart()'));
self::assertNotFalse(has_filter('woocommerce_add_cart_item_data', 'Magdev\WcComposableProduct\CartHandler->add_cart_item_data()'));
self::assertNotFalse(has_filter('woocommerce_get_cart_item_from_session', 'Magdev\WcComposableProduct\CartHandler->get_cart_item_from_session()'));
self::assertNotFalse(has_filter('woocommerce_get_item_data', 'Magdev\WcComposableProduct\CartHandler->display_cart_item_data()'));
self::assertNotFalse(has_action('woocommerce_before_calculate_totals', 'Magdev\WcComposableProduct\CartHandler->calculate_cart_item_price()'));
self::assertNotFalse(has_action('woocommerce_single_product_summary', 'Magdev\WcComposableProduct\CartHandler->render_product_selector()'));
self::assertNotFalse(has_filter('woocommerce_is_purchasable', 'Magdev\WcComposableProduct\CartHandler->hide_default_add_to_cart()'));
}
public function testHideDefaultAddToCart_ReturnsFalseForComposable(): void
{
$handler = new CartHandler();
$product = $this->createProductMock(['get_type' => 'composable']);
$result = $handler->hide_default_add_to_cart(true, $product);
$this->assertFalse($result);
}
public function testHideDefaultAddToCart_PassesThroughForSimple(): void
{
$handler = new CartHandler();
$product = $this->createProductMock(['get_type' => 'simple']);
$result = $handler->hide_default_add_to_cart(true, $product);
$this->assertTrue($result);
}
public function testValidateAddToCart_PassesThroughForNonComposable(): void
{
$product = $this->createProductMock(['get_type' => 'simple']);
Functions\expect('wc_get_product')->with(1)->andReturn($product);
$handler = new CartHandler();
$result = $handler->validate_add_to_cart(true, 1, 1);
$this->assertTrue($result);
}
public function testValidateAddToCart_ReturnsFalseWhenNoProductsSelected(): void
{
$product = $this->createProductMock(['get_type' => 'composable']);
Functions\expect('wc_get_product')->with(1)->andReturn($product);
Functions\expect('wc_add_notice')->once();
$_POST['composable_products'] = [];
$handler = new CartHandler();
$result = $handler->validate_add_to_cart(true, 1, 1);
$this->assertFalse($result);
}
public function testValidateAddToCart_ReturnsFalseWhenNoPostData(): void
{
$product = $this->createProductMock(['get_type' => 'composable']);
Functions\expect('wc_get_product')->with(1)->andReturn($product);
Functions\expect('wc_add_notice')->once();
// No $_POST['composable_products'] at all
$handler = new CartHandler();
$result = $handler->validate_add_to_cart(true, 1, 1);
$this->assertFalse($result);
}
public function testAddCartItemData_AddsSelectionsForComposable(): void
{
$product = $this->createProductMock(['get_type' => 'composable']);
Functions\expect('wc_get_product')->with(1)->andReturn($product);
Functions\expect('absint')->andReturnUsing(function ($val) {
return abs((int) $val);
});
$_POST['composable_products'] = ['101', '102'];
$handler = new CartHandler();
$result = $handler->add_cart_item_data([], 1);
$this->assertArrayHasKey('composable_products', $result);
$this->assertSame([101, 102], $result['composable_products']);
$this->assertArrayHasKey('unique_key', $result);
}
public function testAddCartItemData_PassesThroughForNonComposable(): void
{
$product = $this->createProductMock(['get_type' => 'simple']);
Functions\expect('wc_get_product')->with(1)->andReturn($product);
$handler = new CartHandler();
$result = $handler->add_cart_item_data(['existing' => 'data'], 1);
$this->assertSame(['existing' => 'data'], $result);
}
public function testGetCartItemFromSession_RestoresComposableProducts(): void
{
$handler = new CartHandler();
$result = $handler->get_cart_item_from_session(
['data' => 'test'],
['composable_products' => [101, 102]]
);
$this->assertSame([101, 102], $result['composable_products']);
}
public function testGetCartItemFromSession_PassesThroughWithoutComposableData(): void
{
$handler = new CartHandler();
$result = $handler->get_cart_item_from_session(
['data' => 'test'],
[]
);
$this->assertArrayNotHasKey('composable_products', $result);
}
public function testDisplayCartItemData_FormatsProductNames(): void
{
$mock1 = $this->createProductMock(['get_name' => 'Product A']);
$mock2 = $this->createProductMock(['get_name' => 'Product B']);
Functions\expect('wc_get_product')
->andReturnUsing(function ($id) use ($mock1, $mock2) {
return match ($id) {
101 => $mock1,
102 => $mock2,
default => false,
};
});
$handler = new CartHandler();
$result = $handler->display_cart_item_data(
[],
['composable_products' => [101, 102]]
);
$this->assertCount(1, $result);
$this->assertStringContainsString('Product A', $result[0]['value']);
$this->assertStringContainsString('Product B', $result[0]['value']);
}
public function testDisplayCartItemData_ReturnsEmptyForNonComposable(): void
{
$handler = new CartHandler();
$result = $handler->display_cart_item_data([], []);
$this->assertSame([], $result);
}
}

72
tests/Unit/PluginTest.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
/**
* Plugin Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\Plugin;
use Brain\Monkey\Functions;
class PluginTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Reset the singleton instance between tests
$reflection = new \ReflectionClass(Plugin::class);
$property = $reflection->getProperty('instance');
$property->setAccessible(true);
$property->setValue(null, null);
}
public function testInstance_ReturnsSingleton(): void
{
$instance1 = Plugin::instance();
$instance2 = Plugin::instance();
$this->assertSame($instance1, $instance2);
}
public function testInstance_ReturnsPluginClass(): void
{
$instance = Plugin::instance();
$this->assertInstanceOf(Plugin::class, $instance);
}
public function testAddProductType_AddsComposableToTypes(): void
{
$plugin = Plugin::instance();
$types = $plugin->add_product_type([]);
$this->assertArrayHasKey('composable', $types);
}
public function testProductClass_ReturnsCustomClassForComposable(): void
{
$plugin = Plugin::instance();
$class = $plugin->product_class('WC_Product', 'composable');
$this->assertSame('Magdev\WcComposableProduct\ProductType', $class);
}
public function testProductClass_PassesThroughForOtherTypes(): void
{
$plugin = Plugin::instance();
$class = $plugin->product_class('WC_Product', 'simple');
$this->assertSame('WC_Product', $class);
}
public function testGetTwig_ReturnsTwigEnvironment(): void
{
$plugin = Plugin::instance();
$this->assertInstanceOf(\Twig\Environment::class, $plugin->get_twig());
}
}

View File

@@ -0,0 +1,218 @@
<?php
/**
* ProductType Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\ProductType;
use Brain\Monkey\Functions;
class ProductTypeTest extends TestCase
{
private function createProductType(array $meta = []): ProductType
{
$product = new ProductType();
foreach ($meta as $key => $value) {
$product->update_meta_data($key, $value);
}
return $product;
}
public function testGetType_ReturnsComposable(): void
{
$product = $this->createProductType();
$this->assertSame('composable', $product->get_type());
}
public function testIsPurchasable_ReturnsTrue(): void
{
$product = $this->createProductType();
$this->assertTrue($product->is_purchasable());
}
public function testIsSoldIndividually_ReturnsTrue(): void
{
$product = $this->createProductType();
$this->assertTrue($product->is_sold_individually());
}
public function testGetSelectionLimit_UsesProductMeta(): void
{
Functions\expect('absint')->once()->andReturnUsing(function ($val) {
return abs((int) $val);
});
$product = $this->createProductType(['_composable_selection_limit' => '3']);
$this->assertSame(3, $product->get_selection_limit());
}
public function testGetSelectionLimit_FallsBackToGlobalDefault(): void
{
Functions\expect('get_option')
->once()
->with('wc_composable_default_limit', 5)
->andReturn(7);
Functions\expect('absint')->once()->andReturnUsing(function ($val) {
return abs((int) $val);
});
$product = $this->createProductType();
$this->assertSame(7, $product->get_selection_limit());
}
public function testGetSelectionLimit_FallsBackToHardDefault(): void
{
Functions\expect('get_option')
->once()
->with('wc_composable_default_limit', 5)
->andReturn(5);
Functions\expect('absint')->once()->andReturnUsing(function ($val) {
return abs((int) $val);
});
$product = $this->createProductType();
$this->assertSame(5, $product->get_selection_limit());
}
public function testGetPricingMode_UsesProductMeta(): void
{
$product = $this->createProductType(['_composable_pricing_mode' => 'fixed']);
$this->assertSame('fixed', $product->get_pricing_mode());
}
public function testGetPricingMode_FallsBackToGlobalDefault(): void
{
Functions\expect('get_option')
->once()
->with('wc_composable_default_pricing', 'sum')
->andReturn('sum');
$product = $this->createProductType();
$this->assertSame('sum', $product->get_pricing_mode());
}
public function testShouldIncludeUnpublished_PerProductYes(): void
{
$product = $this->createProductType(['_composable_include_unpublished' => 'yes']);
$this->assertTrue($product->should_include_unpublished());
}
public function testShouldIncludeUnpublished_PerProductNo(): void
{
$product = $this->createProductType(['_composable_include_unpublished' => 'no']);
$this->assertFalse($product->should_include_unpublished());
}
public function testShouldIncludeUnpublished_FallsBackToGlobalYes(): void
{
Functions\expect('get_option')
->once()
->with('wc_composable_include_unpublished', 'no')
->andReturn('yes');
$product = $this->createProductType();
$this->assertTrue($product->should_include_unpublished());
}
public function testShouldIncludeUnpublished_FallsBackToGlobalNo(): void
{
Functions\expect('get_option')
->once()
->with('wc_composable_include_unpublished', 'no')
->andReturn('no');
$product = $this->createProductType();
$this->assertFalse($product->should_include_unpublished());
}
public function testGetSelectionCriteria_DefaultsToCategory(): void
{
$product = $this->createProductType();
$criteria = $product->get_selection_criteria();
$this->assertSame('category', $criteria['type']);
$this->assertSame([], $criteria['categories']);
$this->assertSame([], $criteria['tags']);
$this->assertSame('', $criteria['skus']);
}
public function testGetSelectionCriteria_UsesProductMeta(): void
{
$product = $this->createProductType([
'_composable_criteria_type' => 'tag',
'_composable_tags' => [5, 10],
]);
$criteria = $product->get_selection_criteria();
$this->assertSame('tag', $criteria['type']);
$this->assertSame([5, 10], $criteria['tags']);
}
public function testCalculateComposedPrice_FixedMode(): void
{
$product = $this->createProductType(['_composable_pricing_mode' => 'fixed']);
// Set regular price via the stub's data property
$reflection = new \ReflectionClass($product);
$dataProp = $reflection->getProperty('data');
$dataProp->setAccessible(true);
$data = $dataProp->getValue($product);
$data['regular_price'] = '25.00';
$dataProp->setValue($product, $data);
$price = $product->calculate_composed_price([101, 102]);
$this->assertSame(25.0, $price);
}
public function testCalculateComposedPrice_SumMode(): void
{
Functions\expect('get_option')
->with('wc_composable_default_pricing', 'sum')
->andReturn('sum');
$mock1 = $this->createProductMock(['get_price' => '5.00']);
$mock2 = $this->createProductMock(['get_price' => '7.50']);
Functions\expect('wc_get_product')
->andReturnUsing(function ($id) use ($mock1, $mock2) {
return match ($id) {
101 => $mock1,
102 => $mock2,
default => false,
};
});
$product = $this->createProductType();
$price = $product->calculate_composed_price([101, 102]);
$this->assertSame(12.5, $price);
}
public function testCalculateComposedPrice_SumMode_SkipsInvalidProducts(): void
{
Functions\expect('get_option')
->with('wc_composable_default_pricing', 'sum')
->andReturn('sum');
$mock1 = $this->createProductMock(['get_price' => '5.00']);
Functions\expect('wc_get_product')
->andReturnUsing(function ($id) use ($mock1) {
return match ($id) {
101 => $mock1,
default => false,
};
});
$product = $this->createProductType();
$price = $product->calculate_composed_price([101, 999]);
$this->assertSame(5.0, $price);
}
}

View File

@@ -0,0 +1,226 @@
<?php
/**
* StockManager Tests
*
* @package Magdev\WcComposableProduct\Tests
*/
namespace Magdev\WcComposableProduct\Tests\Unit;
use Magdev\WcComposableProduct\Tests\TestCase;
use Magdev\WcComposableProduct\StockManager;
use Brain\Monkey\Functions;
class StockManagerTest extends TestCase
{
private StockManager $manager;
protected function setUp(): void
{
parent::setUp();
$this->manager = new StockManager();
}
// --- validate_stock_availability ---
public function testValidateStock_ReturnsTrueWhenAllInStock(): void
{
$mock = $this->createProductMock([
'managing_stock' => false,
'is_in_stock' => true,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
Functions\expect('wc_get_product')->with(2)->andReturn($mock);
$result = $this->manager->validate_stock_availability([1, 2]);
$this->assertTrue($result);
}
public function testValidateStock_ReturnsTrueWhenNotManagingStock(): void
{
$mock = $this->createProductMock(['managing_stock' => false]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$result = $this->manager->validate_stock_availability([1]);
$this->assertTrue($result);
}
public function testValidateStock_ReturnsErrorForOutOfStock(): void
{
$mock = $this->createProductMock([
'managing_stock' => true,
'is_in_stock' => false,
'get_name' => 'Widget',
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$result = $this->manager->validate_stock_availability([1]);
$this->assertIsString($result);
$this->assertStringContainsString('Widget', $result);
}
public function testValidateStock_ReturnsErrorForInsufficientQuantity(): void
{
$mock = $this->createProductMock([
'managing_stock' => true,
'is_in_stock' => true,
'get_stock_quantity' => 1,
'get_name' => 'Widget',
'backorders_allowed' => false,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$result = $this->manager->validate_stock_availability([1], 5);
$this->assertIsString($result);
$this->assertStringContainsString('Widget', $result);
}
public function testValidateStock_PassesWhenBackordersAllowed(): void
{
// When stock_quantity is null the insufficient-stock check is skipped,
// and the backorders_allowed() branch is reached.
$mock = $this->createProductMock([
'managing_stock' => true,
'is_in_stock' => true,
'get_stock_quantity' => null,
'backorders_allowed' => true,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$result = $this->manager->validate_stock_availability([1], 5);
$this->assertTrue($result);
}
public function testValidateStock_SkipsNullProducts(): void
{
Functions\expect('wc_get_product')->with(999)->andReturn(false);
$result = $this->manager->validate_stock_availability([999]);
$this->assertTrue($result);
}
// --- get_product_stock_info ---
public function testGetProductStockInfo_ReturnsCorrectStructure(): void
{
$mock = $this->createProductMock([
'is_in_stock' => true,
'get_stock_quantity' => 10,
'backorders_allowed' => false,
'get_stock_status' => 'instock',
'managing_stock' => true,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$info = $this->manager->get_product_stock_info(1);
$this->assertTrue($info['in_stock']);
$this->assertSame(10, $info['stock_quantity']);
$this->assertFalse($info['backorders_allowed']);
$this->assertSame('instock', $info['stock_status']);
$this->assertTrue($info['managing_stock']);
$this->assertTrue($info['has_enough_stock']);
}
public function testGetProductStockInfo_ReturnsFallbackForInvalidProduct(): void
{
Functions\expect('wc_get_product')->with(999)->andReturn(false);
$info = $this->manager->get_product_stock_info(999);
$this->assertFalse($info['in_stock']);
$this->assertSame(0, $info['stock_quantity']);
$this->assertFalse($info['backorders_allowed']);
$this->assertSame('outofstock', $info['stock_status']);
}
public function testGetProductStockInfo_HasEnoughStockTrueWhenNotManaging(): void
{
$mock = $this->createProductMock([
'managing_stock' => false,
'get_stock_quantity' => null,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$info = $this->manager->get_product_stock_info(1, 100);
$this->assertTrue($info['has_enough_stock']);
}
public function testGetProductStockInfo_HasEnoughStockFalseWhenInsufficient(): void
{
$mock = $this->createProductMock([
'managing_stock' => true,
'get_stock_quantity' => 2,
]);
Functions\expect('wc_get_product')->with(1)->andReturn($mock);
$info = $this->manager->get_product_stock_info(1, 5);
$this->assertFalse($info['has_enough_stock']);
}
// --- prevent_composable_stock_reduction ---
public function testPreventStockReduction_ReturnsFalseForComposableItem(): void
{
$productMock = $this->createProductMock(['get_type' => 'composable']);
$itemMock = \Mockery::mock('WC_Order_Item_Product');
$itemMock->shouldReceive('get_product')->andReturn($productMock);
$orderMock = \Mockery::mock('WC_Order');
$orderMock->shouldReceive('get_items')->andReturn([$itemMock]);
$result = $this->manager->prevent_composable_stock_reduction(true, $orderMock);
$this->assertFalse($result);
}
public function testPreventStockReduction_PassesThroughForNonComposable(): void
{
$productMock = $this->createProductMock(['get_type' => 'simple']);
$itemMock = \Mockery::mock('WC_Order_Item_Product');
$itemMock->shouldReceive('get_product')->andReturn($productMock);
$orderMock = \Mockery::mock('WC_Order');
$orderMock->shouldReceive('get_items')->andReturn([$itemMock]);
$result = $this->manager->prevent_composable_stock_reduction(true, $orderMock);
$this->assertTrue($result);
}
// --- store_selected_products_in_order ---
public function testStoreSelectedProducts_AddsMetaWhenPresent(): void
{
$itemMock = \Mockery::mock('WC_Order_Item_Product');
$itemMock->shouldReceive('add_meta_data')
->once()
->with('_composable_products', [1, 2], true);
$this->manager->store_selected_products_in_order(
$itemMock,
'cart_key',
['composable_products' => [1, 2]]
);
}
public function testStoreSelectedProducts_DoesNothingWithoutData(): void
{
$itemMock = \Mockery::mock('WC_Order_Item_Product');
$itemMock->shouldNotReceive('add_meta_data');
$this->manager->store_selected_products_in_order(
$itemMock,
'cart_key',
[]
);
}
}

33
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
/**
* PHPUnit Bootstrap
*
* Sets up WooCommerce stubs, plugin constants, and the Composer autoloader
* for running unit tests without a full WordPress installation.
*
* @package Magdev\WcComposableProduct\Tests
*/
// Composer autoloader (loads Brain Monkey, Mockery, plugin classes)
require_once dirname(__DIR__) . '/vendor/autoload.php';
// Define WordPress constants that the plugin expects
defined('ABSPATH') || define('ABSPATH', '/tmp/wordpress/');
defined('WP_DEBUG') || define('WP_DEBUG', true);
defined('DOING_AJAX') || define('DOING_AJAX', false);
// Define plugin constants
defined('WC_COMPOSABLE_PRODUCT_VERSION') || define('WC_COMPOSABLE_PRODUCT_VERSION', '1.3.1');
defined('WC_COMPOSABLE_PRODUCT_FILE') || define('WC_COMPOSABLE_PRODUCT_FILE', dirname(__DIR__) . '/wc-composable-product.php');
defined('WC_COMPOSABLE_PRODUCT_PATH') || define('WC_COMPOSABLE_PRODUCT_PATH', dirname(__DIR__) . '/');
defined('WC_COMPOSABLE_PRODUCT_URL') || define('WC_COMPOSABLE_PRODUCT_URL', 'https://example.com/wp-content/plugins/wc-composable-product/');
defined('WC_COMPOSABLE_PRODUCT_BASENAME') || define('WC_COMPOSABLE_PRODUCT_BASENAME', 'wc-composable-product/wc-composable-product.php');
// Load WooCommerce class stubs (parent before child)
require_once __DIR__ . '/stubs/class-wc-data.php';
require_once __DIR__ . '/stubs/class-wc-product.php';
require_once __DIR__ . '/stubs/class-wc-settings-page.php';
require_once __DIR__ . '/stubs/class-wc-order.php';
require_once __DIR__ . '/stubs/class-wc-order-item-product.php';
require_once __DIR__ . '/stubs/class-wc-cart.php';
require_once __DIR__ . '/stubs/class-wc-admin-settings.php';

View File

@@ -0,0 +1,11 @@
<?php
/**
* Minimal WC_Admin_Settings stub for unit testing.
*/
class WC_Admin_Settings {
public static function output_fields($options) {
}
public static function save_fields($options) {
}
}

View File

@@ -0,0 +1,15 @@
<?php
/**
* Minimal WC_Cart stub for unit testing.
*/
class WC_Cart {
protected $cart_contents = [];
public function get_cart() {
return $this->cart_contents;
}
public function set_cart_contents($contents) {
$this->cart_contents = $contents;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* Minimal WC_Data stub for unit testing.
*/
class WC_Data {
protected $id = 0;
protected $data = [];
protected $meta_data = [];
public function __construct($id = 0) {
if (is_numeric($id) && $id > 0) {
$this->id = (int) $id;
}
}
public function get_id() {
return $this->id;
}
public function set_id($id) {
$this->id = (int) $id;
}
public function get_meta($key, $single = true, $context = 'view') {
return $this->meta_data[$key] ?? ($single ? '' : []);
}
public function update_meta_data($key, $value, $meta_id = 0) {
$this->meta_data[$key] = $value;
}
public function save() {
return $this->get_id();
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* Minimal WC_Order_Item_Product stub for unit testing.
*/
class WC_Order_Item_Product extends WC_Data {
protected $product = null;
protected $quantity = 1;
public function get_product() {
return $this->product;
}
public function set_product($product) {
$this->product = $product;
}
public function get_quantity() {
return $this->quantity;
}
public function set_quantity($quantity) {
$this->quantity = $quantity;
}
public function add_meta_data($key, $value, $unique = false) {
$this->meta_data[$key] = $value;
}
}

View File

@@ -0,0 +1,24 @@
<?php
/**
* Minimal WC_Order stub for unit testing.
*/
class WC_Order extends WC_Data {
protected $items = [];
protected $order_notes = [];
public function get_items($type = '') {
return $this->items;
}
public function set_items($items) {
$this->items = $items;
}
public function add_order_note($note) {
$this->order_notes[] = $note;
}
public function get_order_notes() {
return $this->order_notes;
}
}

View File

@@ -0,0 +1,88 @@
<?php
/**
* Minimal WC_Product stub for unit testing.
*/
class WC_Product extends WC_Data {
protected $supports = [];
protected $data = [
'name' => '',
'price' => '',
'regular_price' => '',
'status' => 'publish',
];
public function get_type() {
return 'simple';
}
public function get_name($context = 'view') {
return $this->data['name'] ?? '';
}
public function get_price($context = 'view') {
return $this->data['price'] ?? '';
}
public function get_regular_price($context = 'view') {
return $this->data['regular_price'] ?? '';
}
public function get_price_html() {
return '';
}
public function get_permalink() {
return '';
}
public function get_image_id($context = 'view') {
return 0;
}
public function get_stock_quantity($context = 'view') {
return null;
}
public function get_stock_status($context = 'view') {
return 'instock';
}
public function get_children() {
return [];
}
public function is_type($type) {
return $this->get_type() === $type;
}
public function is_purchasable() {
return true;
}
public function is_in_stock() {
return true;
}
public function managing_stock() {
return false;
}
public function backorders_allowed() {
return false;
}
public function is_sold_individually() {
return false;
}
public function set_price($price) {
$this->data['price'] = $price;
}
public function set_stock_quantity($quantity) {
}
public function supports($feature) {
return in_array($feature, $this->supports, true);
}
}

View File

@@ -0,0 +1,29 @@
<?php
/**
* Minimal WC_Settings_Page stub for unit testing.
*/
class WC_Settings_Page {
protected $id = '';
protected $label = '';
public function __construct() {
}
public function get_id() {
return $this->id;
}
public function get_label() {
return $this->label;
}
public function get_settings() {
return [];
}
public function output() {
}
public function save() {
}
}

View File

@@ -4,7 +4,7 @@
* Plugin Name: WooCommerce Composable Products * Plugin Name: WooCommerce Composable Products
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-composable-product * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-composable-product
* Description: Create composable products where customers select a limited number of items from a configurable set * Description: Create composable products where customers select a limited number of items from a configurable set
* Version: 1.2.1 * Version: 1.3.1
* Author: Marco Graetsch * Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev * Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL v3 or later * License: GPL v3 or later
@@ -17,74 +17,80 @@
* 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.2.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';
} }
/** /**
* 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',
<div class="notice notice-error"> function () {
<p><?php esc_html_e('WooCommerce Composable Products requires WooCommerce to be installed and active.', 'wc-composable-product'); ?></p> ?>
</div> <div class="notice notice-error">
<?php <p><?php esc_html_e( 'WooCommerce Composable Products requires WooCommerce to be installed and active.', 'wc-composable-product' ); ?></p>
}); </div>
return false; <?php
} }
return true; );
return false;
}
return true;
} }
/** /**
* 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
WC_Composable_Product\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' );