Initial implementation of WooCommerce Composable Products plugin

Implemented custom WooCommerce product type allowing customers to build their own
product bundles by selecting from predefined sets of products.

Features:
- Custom "Composable Product" type with admin interface
- Product selection by category, tag, or SKU
- Configurable selection limits (global and per-product)
- Dual pricing modes: fixed price or sum of selected products
- Modern responsive frontend with Twig templates
- AJAX add-to-cart functionality
- Full internationalization support (.pot file)
- WooCommerce settings integration
- Comprehensive documentation

Technical implementation:
- PHP 8.3+ with PSR-4 autoloading
- Twig 3.0 templating engine via Composer
- Vanilla JavaScript with jQuery for frontend interactions
- WordPress and WooCommerce hooks for seamless integration
- Security: input sanitization, validation, and output escaping
- Translation-ready with text domain 'wc-composable-product'

Documentation:
- README.md: Project overview and features
- INSTALL.md: Installation and usage guide
- IMPLEMENTATION.md: Technical architecture
- CHANGELOG.md: Version history

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 00:38:29 +01:00
commit 1edb0be3d9
21 changed files with 2628 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Linked sources
wp-core
wp-plugins
# Editor swap files
*.*swp
# Composer
vendor/
composer.lock
# Cache
cache/
# Development files
.vscode/
.idea/
*.log
# OS files
.DS_Store
Thumbs.db
# Release files
*.zip
releases/

34
CHANGELOG.md Normal file
View File

@@ -0,0 +1,34 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2024-12-31
### Added
- Initial release
- Composable product type for WooCommerce
- Product selection by category, tag, or SKU
- Configurable selection limits (global and per-product)
- Two pricing modes: fixed price or sum of selected products
- Admin settings page
- Frontend product selector with grid layout
- AJAX add-to-cart functionality
- Twig template engine integration
- Full internationalization support
- Responsive design
- WooCommerce cart integration
- Product data validation
### Features
- Select products from predefined categories, tags, or SKUs
- Limit number of items customers can select
- Visual product selector with images and prices
- Real-time price calculation
- Clean, modern UI
- Mobile responsive
- Translation ready
[1.0.0]: https://github.com/magdev/wc-composable-product/releases/tag/v1.0.0

92
CLAUDE.md Normal file
View File

@@ -0,0 +1,92 @@
# WooCommerce plugin for user composable products - AI Context Document
**Author:** Marco Graetsch
## Project Overview
This plugin implements a special product type, for which users can select a limited number of product from a configurable set of simple or variable products. The limit of selectable products should be a global and per-product setting, for which global is the fallback. The set of selectable products can be defined per category, tag or SKU. The price is either a fixed price or the sum of the prices of the selected products. Think of a package of stickers as composable product, where each package can contain $limit number of stickers.
### Key Fact: 100% AI-Generated
This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase was created through AI assistance.
## Technical Stack
- **Language:** PHP 8.3+
- **Framework:** Latest WordPress Plugin API
- **E-commerce:** WooCommerce 10.0+
- **Template Engine:** Twig 3.0 (via Composer)
- **Frontend:** Vanilla JavaScript + jQuery
- **Styling:** Custom CSS
- **Dependency Management:** Composer
- **Internationalization:** WordPress i18n (.pot/.po/.mo files)
## Implementation Details
### Security Best Practices
- All user inputs are sanitized (integers for quantities/prices)
- Nonce verification on form submissions
- Output escaping in templates (`esc_attr`, `esc_html`, `esc_js`)
- Direct file access prevention via `ABSPATH` check
### Translation Ready
All user-facing strings use:
```php
__('Text to translate', 'wc-composable-product')
_e('Text to translate', 'wc-composable-product')
```
Text domain: `wc-composable-product`
**Available Translations (as of v1.1.22):**
- `en_US` - English (United States)
- `de_DE` - German (Germany, formal)
- `de_DE_informal` - German (Germany, informal "du")
- `de_CH` - German (Switzerland, formal "Sie")
- `de_CH_informal` - German (Switzerland, informal "du")
- `fr_CH` - French (Switzerland)
- `it_CH` - Italian (Switzerland)
Note: Swiss locales use CHF currency formatting in examples (e.g., "CHF 50.-")
### Create releases
**Important Notes**
- The `vendor/` directory MUST be included in releases (Twig dependency required for runtime)
- Running zip from wrong directory creates empty or malformed archives
- Exclusion patterns must match the relative path structure used in zip command
- Always verify the package with `unzip -l` and test extraction before committing
- The `wp-core/` and `wp-plugins/` directories MUST NOT be included in releases!
**Important Git Notes:**
- Always commit from `dev` branch first
- Tags should use format `vX.X.X` (e.g., `v1.1.22`)
- Use annotated tags (`-a`) not lightweight tags
- Commit messages should follow the established format with Claude Code attribution
- `.claude/settings.local.json` changes are typically local-only (stash before rebasing)
#### What Gets Released
- All plugin source files
- Compiled vendor dependencies
- Translation files (.mo compiled from .po)
- Assets (CSS, JS)
- Documentation (README, CHANGELOG, etc.)
#### What's Excluded
- Git metadata (`.git/`)
- Development files (`.vscode/`, `.claude/`, `CLAUDE.md`)
- Logs and cache files
- Previous releases
- `composer.lock` (but `vendor/` is included)
---
Always refer to this document when starting work on this project. Good luck!

406
IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,406 @@
# WooCommerce Composable Products - Implementation Summary
## Overview
This document provides a technical overview of the WooCommerce Composable Products plugin implementation.
**Version:** 1.0.0
**Created:** 2024-12-31
**AI-Generated:** 100% created with Claude.AI assistance
## Architecture
### Plugin Structure
```
wc-composable-product/
├── assets/ # Frontend assets
│ ├── css/
│ │ ├── admin.css # Admin styles
│ │ └── frontend.css # Frontend styles
│ └── js/
│ ├── admin.js # Admin JavaScript
│ └── frontend.js # Frontend JavaScript
├── cache/ # Twig template cache
├── includes/ # PHP classes
│ ├── Admin/
│ │ ├── Product_Data.php # Product data tab
│ │ └── Settings.php # Settings page
│ ├── Cart_Handler.php # Cart integration
│ ├── Plugin.php # Main plugin class
│ ├── Product_Selector.php # Frontend selector
│ └── Product_Type.php # Custom product type
├── languages/ # Translation files
│ └── wc-composable-product.pot
├── templates/ # Twig templates
│ └── product-selector.twig
└── wc-composable-product.php # Main plugin file
```
## Core Components
### 1. Main Plugin Class (`Plugin.php`)
**Responsibilities:**
- Singleton pattern implementation
- Twig template engine initialization
- Hook registration
- Component initialization
- Asset enqueuing
**Key Methods:**
- `instance()`: Get singleton instance
- `init_twig()`: Initialize Twig with WordPress functions
- `render_template()`: Render Twig templates
- `add_product_type()`: Register composable product type
### 2. Product Type (`Product_Type.php`)
**Extends:** `WC_Product`
**Key Features:**
- Custom product type: `composable`
- Selection limit management (per-product or global)
- Pricing mode (fixed or sum)
- Product selection criteria (category/tag/SKU)
- Dynamic product availability
- Price calculation
**Key Methods:**
- `get_selection_limit()`: Get max selectable items
- `get_pricing_mode()`: Get pricing calculation mode
- `get_available_products()`: Query available products
- `calculate_composed_price()`: Calculate final price
### 3. Admin Settings (`Admin/Settings.php`)
**Extends:** `WC_Settings_Page`
**Global Settings:**
- Default selection limit
- Default pricing mode
- Display options (images, prices, total)
**Integration:** Adds tab to WooCommerce Settings
### 4. Product Data Tab (`Admin/Product_Data.php`)
**Responsibilities:**
- Add "Composable Options" tab to product edit page
- Render selection criteria fields
- Save product meta data
- Dynamic field visibility based on criteria type
**Saved Meta:**
- `_composable_selection_limit`: Item limit
- `_composable_pricing_mode`: Pricing calculation
- `_composable_criteria_type`: Selection method
- `_composable_categories`: Selected categories
- `_composable_tags`: Selected tags
- `_composable_skus`: SKU list
### 5. Product Selector (`Product_Selector.php`)
**Responsibilities:**
- Render frontend product selection interface
- Prepare data for Twig template
- Apply display settings
**Template Variables:**
- `products`: Available products array
- `selection_limit`: Max selections
- `pricing_mode`: Pricing calculation
- `show_images/prices/total`: Display flags
### 6. Cart Handler (`Cart_Handler.php`)
**Responsibilities:**
- Validate product selection
- Add selected products to cart data
- Calculate dynamic pricing
- Display selected products in cart
**Hooks:**
- `woocommerce_add_to_cart_validation`: Validate selections
- `woocommerce_add_cart_item_data`: Store selections
- `woocommerce_before_calculate_totals`: Update prices
- `woocommerce_get_item_data`: Display in cart
## Frontend Implementation
### Product Selector Template (`product-selector.twig`)
**Features:**
- Responsive grid layout
- Checkbox-based selection
- Product images and prices
- Real-time total calculation
- AJAX add-to-cart
**Data Attributes:**
- `data-product-id`: Composable product ID
- `data-selection-limit`: Max selections
- `data-pricing-mode`: Pricing mode
- `data-price`: Individual product prices
### JavaScript (`frontend.js`)
**Functionality:**
- Selection limit enforcement
- Visual feedback on selection
- Real-time price updates (sum mode)
- AJAX cart operations
- Error/success messages
**Key Functions:**
- `handleCheckboxChange()`: Selection logic
- `updateTotalPrice()`: Calculate total
- `addToCart()`: AJAX add-to-cart
- `showMessage()`: User feedback
### CSS Styling
**Approach:**
- Grid-based layout (responsive)
- Card-style product items
- Visual selection states
- Mobile-first design
- Breakpoints: 768px, 480px
## Data Flow
### Creating a Composable Product
1. Admin selects "Composable product" type
2. Configure selection limit and pricing mode
3. Choose selection criteria (category/tag/SKU)
4. Save product metadata
5. WooCommerce registers product with custom type
### Frontend Display
1. Customer visits product page
2. `Cart_Handler` renders `Product_Selector`
3. `Product_Type::get_available_products()` queries products
4. Twig template renders grid with products
5. JavaScript handles interactions
### Adding to Cart
1. Customer selects products (JavaScript validation)
2. Click "Add to Cart" button
3. AJAX request with selected product IDs
4. `Cart_Handler::validate_add_to_cart()` validates
5. `Cart_Handler::add_cart_item_data()` stores selections
6. `Cart_Handler::calculate_cart_item_price()` updates price
7. Product added to cart with custom data
### Cart Display
1. WooCommerce loads cart
2. `Cart_Handler::get_cart_item_from_session()` restores data
3. `Cart_Handler::display_cart_item_data()` shows selections
4. Price calculated dynamically on each cart load
## Security Implementation
### Input Sanitization
- **Integers:** `absint()` for IDs and limits
- **Text:** `sanitize_text_field()` for modes and types
- **Textarea:** `sanitize_textarea_field()` for SKUs
- **Arrays:** `array_map()` with sanitization functions
### Output Escaping
- **HTML:** `esc_html()`, `esc_html_e()`
- **Attributes:** `esc_attr()`
- **URLs:** `esc_url()`
- **JavaScript:** Localized scripts with escaped data
### Validation
- Selection limit enforcement
- Product availability verification
- Cart data validation
- Nonce verification (via WooCommerce)
## Internationalization
### Text Domain
`wc-composable-product`
### Translation Functions
- `__()`: Return translated string
- `_e()`: Echo translated string
- `sprintf()` with `__()`: Variable substitution
### POT File
Generated template: `languages/wc-composable-product.pot`
**Supported Locales (per CLAUDE.md):**
- en_US (English)
- de_DE, de_DE_informal (German - Germany)
- de_CH, de_CH_informal (German - Switzerland)
- fr_CH (French - Switzerland)
- it_CH (Italian - Switzerland)
## Performance Considerations
### Caching
- Twig templates cached in `cache/` directory
- Auto-reload enabled in debug mode
- Optimized Composer autoloader
### Database Queries
- Efficient `WP_Query` for product selection
- Meta queries for SKU filtering
- Taxonomy queries for category/tag filtering
### Asset Loading
- Scripts only on relevant pages
- Minification ready (use build tools)
- Conditional enqueuing
## Extensibility
### Hooks & Filters
**Available Filters:**
- `wc_composable_settings`: Modify settings array
- `woocommerce_product_class`: Custom product class
- `product_type_selector`: Product type registration
**Customization Points:**
- Twig templates (override in theme)
- CSS styling (enqueue custom styles)
- JavaScript behavior (extend object)
### Developer API
```php
// Get composable product
$product = wc_get_product($product_id);
// Check if composable
if ($product->get_type() === 'composable') {
// Get available products
$products = $product->get_available_products();
// Get selection limit
$limit = $product->get_selection_limit();
// Calculate price
$price = $product->calculate_composed_price($selected_ids);
}
```
## Testing Checklist
### Admin Testing
- [ ] Product type appears in dropdown
- [ ] Composable Options tab displays
- [ ] Selection criteria toggle works
- [ ] Meta data saves correctly
- [ ] Settings page accessible
- [ ] Global defaults apply
### Frontend Testing
- [ ] Product selector renders
- [ ] Selection limit enforced
- [ ] Price calculation accurate (both modes)
- [ ] AJAX add-to-cart works
- [ ] Cart displays selections
- [ ] Checkout processes correctly
### Edge Cases
- [ ] Empty criteria (no products)
- [ ] Out of stock products excluded
- [ ] Invalid product selections rejected
- [ ] Multiple cart items unique
- [ ] Session persistence
## Known Limitations
1. **Variable Products:** Currently supports simple products in selection
2. **Grouped Products:** Cannot be used as selectable items
3. **Stock Management:** No automatic stock reduction for selected items
4. **Caching:** Template cache needs manual clearing after updates
## Future Enhancements
Potential features for future versions:
- Variable product support in selection
- Quantity selection per item (not just presence)
- Visual bundle previews
- Advanced pricing rules
- Stock management integration
- Product recommendations
- Selection templates/presets
- Multi-currency support enhancements
## Dependencies
### Runtime
- PHP 8.3+
- WordPress 6.0+
- WooCommerce 8.0+
- Twig 3.0 (via Composer)
### Development
- Composer for dependency management
- WP-CLI for i18n operations (optional)
## Deployment
### Production Checklist
1. Run `composer install --no-dev --optimize-autoloader`
2. Ensure `vendor/` directory is included
3. Ensure `cache/` directory is writable
4. Test on staging environment
5. Clear all caches after activation
6. Verify WooCommerce compatibility
### Release Package
Must include:
- All PHP files
- `vendor/` directory
- Assets (CSS, JS)
- Templates
- Language files
- Documentation
Must exclude:
- `.git/` directory
- `composer.lock`
- Development files
- `wp-core/`, `wp-plugins/` symlinks
## Support & Maintenance
### Code Standards
- WordPress Coding Standards
- WooCommerce best practices
- PSR-4 autoloading
- Inline documentation
### Version Control
- Semantic versioning (MAJOR.MINOR.PATCH)
- Changelog maintained
- Annotated git tags
- Development on `dev` branch
---
**Last Updated:** 2024-12-31
**Maintainer:** Marco Graetsch
**AI Assistant:** Claude.AI (Anthropic)

150
INSTALL.md Normal file
View File

@@ -0,0 +1,150 @@
# Installation Guide
## Requirements
Before installing the WooCommerce Composable Products plugin, ensure your system meets these requirements:
- **PHP**: 8.3 or higher
- **WordPress**: 6.0 or higher
- **WooCommerce**: 8.0 or higher
- **Composer**: For dependency management
## Installation Steps
### 1. Upload Plugin Files
Upload the plugin directory to your WordPress installation:
```bash
/wp-content/plugins/wc-composable-product/
```
### 2. Install Dependencies
Navigate to the plugin directory and install dependencies:
```bash
cd /wp-content/plugins/wc-composable-product/
composer install --no-dev --optimize-autoloader
```
### 3. Activate Plugin
1. Log in to your WordPress admin panel
2. Navigate to **Plugins > Installed Plugins**
3. Find "WooCommerce Composable Products"
4. Click **Activate**
### 4. Configure Settings
After activation, configure the plugin:
1. Navigate to **WooCommerce > Settings**
2. Click on the **Composable Products** tab
3. Configure default settings:
- **Default Selection Limit**: Number of items customers can select (default: 5)
- **Default Pricing Mode**: Choose between "Sum of selected products" or "Fixed price"
- **Display Options**: Toggle product images, prices, and totals
## Creating Your First Composable Product
### Step 1: Create a New Product
1. Go to **Products > Add New**
2. Enter a product name (e.g., "Custom Sticker Pack")
### Step 2: Set Product Type
1. In the **Product Data** panel, select **Composable product** from the dropdown
### Step 3: Configure General Settings
In the **General** tab:
- Set a **Regular price** (used if pricing mode is "Fixed")
- Configure **Selection Limit** (leave empty to use global default)
- Choose **Pricing Mode** (leave empty to use global default)
### Step 4: Configure Composable Options
Click on the **Composable Options** tab:
1. **Selection Criteria**: Choose how to select available products
- **By Category**: Select product categories
- **By Tag**: Select product tags
- **By SKU**: Enter comma-separated SKUs
2. Based on your selection:
- **Categories**: Select one or more categories from the dropdown
- **Tags**: Select one or more tags from the dropdown
- **SKUs**: Enter SKUs like: `STICKER-01, STICKER-02, STICKER-03`
### Step 5: Publish
Click **Publish** to make your composable product live.
## Frontend Usage
When customers visit your composable product:
1. They see a grid of available products based on your criteria
2. They can select up to the configured limit
3. The total price updates in real-time (if using sum pricing mode)
4. Click "Add to Cart" to add the composition to their cart
5. Selected products are displayed in the cart
## Troubleshooting
### Plugin Won't Activate
- Ensure WooCommerce is installed and activated first
- Check PHP version (must be 8.3+)
- Verify Composer dependencies are installed
### Products Not Showing in Selector
- Check that products are published and in stock
- Verify the selection criteria (category/tag/SKU) is correct
- Ensure products match the criteria you configured
### Twig Template Errors
- Ensure the `vendor/` directory exists and contains Twig
- Run `composer install` again
- Check that the `cache/` directory is writable
### JavaScript Not Working
- Clear browser cache
- Check browser console for errors
- Ensure jQuery is loaded (WooCommerce includes it)
## Updating
When updating the plugin:
1. Deactivate the plugin
2. Replace plugin files
3. Run `composer install --no-dev --optimize-autoloader`
4. Reactivate the plugin
5. Clear all caches (WordPress, browser, CDN)
## Uninstallation
To completely remove the plugin:
1. Deactivate the plugin
2. Delete the plugin from the Plugins page
3. Optionally clean up database entries (WooCommerce will handle this automatically)
## Support
For issues and feature requests:
- GitHub: https://github.com/magdev/wc-composable-product/issues
- Documentation: See README.md
## Next Steps
- Customize the template by editing `templates/product-selector.twig`
- Modify styles in `assets/css/frontend.css`
- Translate the plugin using the provided `.pot` file
- Create categories/tags for easier product organization

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2024 Marco Graetsch
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
---
For the full text of the GPL v3 license, visit:
https://www.gnu.org/licenses/gpl-3.0.txt

77
README.md Normal file
View File

@@ -0,0 +1,77 @@
# WooCommerce Composable Products
Create composable products where customers can select a limited number of items from a configurable set of products.
## Description
This plugin adds a new product type to WooCommerce that allows customers to build their own product bundles by selecting from a predefined set of simple or variable products. Think of it as a "build your own gift box" or "create your sticker pack" feature.
### Key Features
- **Custom Product Type**: New "Composable Product" type in WooCommerce
- **Flexible Selection**: Define available products by category, tag, or SKU
- **Configurable Limits**: Set global or per-product selection limits
- **Pricing Options**: Fixed price or sum of selected products
- **Multi-language Support**: Fully translatable with i18n support
- **Modern UI**: Clean interface built with Twig templates and vanilla JavaScript
## Requirements
- PHP 8.3 or higher
- WordPress 6.0 or higher
- WooCommerce 8.0 or higher
## Installation
1. Upload the plugin files to `/wp-content/plugins/wc-composable-product/`
2. Run `composer install --no-dev` in the plugin directory
3. Activate the plugin through the 'Plugins' menu in WordPress
4. Configure global settings under WooCommerce > Settings > Composable Products
## Usage
### Creating a Composable Product
1. Go to Products > Add New
2. Select "Composable Product" as the product type
3. Configure product details:
- Set the selection limit (or use global default)
- Choose pricing mode (fixed or sum)
- Define available products by category, tag, or SKU
4. Publish the product
### Global Settings
Navigate to WooCommerce > Settings > Composable Products to configure:
- Default selection limit
- Default pricing mode
- Display options
## Development
This project was created with AI assistance (Claude.AI) and follows WordPress and WooCommerce best practices.
### Building from Source
```bash
composer install
```
### Translation
Generate POT file:
```bash
wp i18n make-pot . languages/wc-composable-product.pot
```
## License
GPL v3 or later - see LICENSE file for details
## Author
Marco Graetsch
## Support
For issues and feature requests, please use the GitHub issue tracker.

43
assets/css/admin.css Normal file
View File

@@ -0,0 +1,43 @@
/**
* Admin Styles for Composable Products
*
* @package WC_Composable_Product
*/
#composable_product_data {
padding: 12px;
}
.composable_criteria_group {
border-top: 1px solid #eee;
padding-top: 12px;
margin-top: 12px;
}
#_composable_categories,
#_composable_tags {
min-height: 150px;
}
.show_if_composable {
display: none;
}
.product-type-composable .show_if_composable {
display: block;
}
.composable_options.composable_tab a::before {
content: '\f323';
font-family: 'Dashicons';
}
/* Enhanced select styling */
.wc-composable-product-admin .select2-container {
min-width: 50%;
}
/* Help tips */
.woocommerce-help-tip {
color: #666;
}

201
assets/css/frontend.css Normal file
View File

@@ -0,0 +1,201 @@
/**
* Frontend Styles for Composable Products
*
* @package WC_Composable_Product
*/
.wc-composable-product-selector {
margin: 2rem 0;
padding: 2rem;
background: #f9f9f9;
border-radius: 8px;
}
.composable-header {
margin-bottom: 2rem;
text-align: center;
}
.composable-header h3 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
color: #333;
}
.selection-info {
margin: 0;
color: #666;
font-size: 0.95rem;
}
.composable-products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.composable-product-item {
position: relative;
background: #fff;
border: 2px solid #e0e0e0;
border-radius: 6px;
transition: all 0.3s ease;
cursor: pointer;
}
.composable-product-item:hover {
border-color: #999;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.composable-product-item.selected {
border-color: #2c3e50;
background: #f0f8ff;
}
.product-item-label {
display: block;
padding: 1rem;
cursor: pointer;
margin: 0;
}
.composable-product-checkbox {
position: absolute;
opacity: 0;
cursor: pointer;
}
.product-item-image {
margin-bottom: 1rem;
text-align: center;
}
.product-item-image img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.product-item-details {
text-align: center;
}
.product-item-name {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
color: #333;
}
.product-item-price {
color: #666;
font-size: 0.9rem;
}
.product-item-checkmark {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 24px;
height: 24px;
background: #fff;
border: 2px solid #ddd;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.composable-product-item.selected .product-item-checkmark::after {
content: '✓';
color: #2c3e50;
font-weight: bold;
font-size: 16px;
}
.composable-total {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
background: #fff;
border-radius: 6px;
margin-bottom: 1.5rem;
font-size: 1.2rem;
}
.total-label {
font-weight: 600;
color: #333;
}
.total-price {
font-weight: 700;
color: #2c3e50;
font-size: 1.5rem;
}
.composable-actions {
text-align: center;
}
.composable-add-to-cart {
padding: 1rem 2rem;
font-size: 1.1rem;
min-width: 200px;
}
.composable-add-to-cart.loading {
opacity: 0.6;
cursor: wait;
}
.composable-messages {
margin-top: 1rem;
}
.composable-message {
padding: 1rem;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.composable-message-error {
background: #fee;
color: #c00;
border: 1px solid #fcc;
}
.composable-message-success {
background: #efe;
color: #060;
border: 1px solid #cfc;
}
/* Responsive design */
@media (max-width: 768px) {
.composable-products-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.wc-composable-product-selector {
padding: 1rem;
}
.composable-total {
font-size: 1rem;
}
.total-price {
font-size: 1.2rem;
}
}
@media (max-width: 480px) {
.composable-products-grid {
grid-template-columns: 1fr;
}
}

50
assets/js/admin.js Normal file
View File

@@ -0,0 +1,50 @@
/**
* Admin JavaScript for Composable Products
*
* @package WC_Composable_Product
*/
(function($) {
'use strict';
$(document).ready(function() {
/**
* Show/hide product data tab for composable products
*/
$('select#product-type').on('change', function() {
const productType = $(this).val();
if (productType === 'composable') {
$('.show_if_composable').show();
$('.hide_if_composable').hide();
$('#composable_product_data').show();
$('.product_data_tabs .composable_options a').show();
} else {
$('.show_if_composable').hide();
$('#composable_product_data').hide();
$('.product_data_tabs .composable_options a').hide();
}
}).trigger('change');
/**
* Toggle criteria groups based on selected type
*/
$('#_composable_criteria_type').on('change', function() {
const criteriaType = $(this).val();
$('.composable_criteria_group').hide();
$('#composable_criteria_' + criteriaType).show();
}).trigger('change');
/**
* Initialize enhanced select for categories and tags
*/
if ($.fn.selectWoo) {
$('#_composable_categories, #_composable_tags').selectWoo({
placeholder: 'Select options...',
allowClear: true
});
}
});
})(jQuery);

186
assets/js/frontend.js Normal file
View File

@@ -0,0 +1,186 @@
/**
* Frontend JavaScript for Composable Products
*
* @package WC_Composable_Product
*/
(function($) {
'use strict';
const ComposableProduct = {
/**
* Initialize
*/
init: function() {
this.bindEvents();
},
/**
* Bind events
*/
bindEvents: function() {
const self = this;
// Handle checkbox changes
$(document).on('change', '.composable-product-checkbox', function() {
self.handleCheckboxChange($(this));
});
// Handle add to cart button
$(document).on('click', '.composable-add-to-cart', function(e) {
e.preventDefault();
self.addToCart($(this));
});
},
/**
* Handle checkbox change
*
* @param {jQuery} $checkbox Checkbox element
*/
handleCheckboxChange: function($checkbox) {
const $container = $checkbox.closest('.wc-composable-product-selector');
const selectionLimit = parseInt($container.data('selection-limit'));
const pricingMode = $container.data('pricing-mode');
const $checked = $container.find('.composable-product-checkbox:checked');
// Enforce selection limit
if ($checked.length > selectionLimit) {
$checkbox.prop('checked', false);
this.showMessage($container, wcComposableProduct.i18n.max_items, 'error');
return;
}
// Update visual state
$checkbox.closest('.composable-product-item').toggleClass('selected', $checkbox.is(':checked'));
// Update total price if in sum mode
if (pricingMode === 'sum') {
this.updateTotalPrice($container);
}
// Clear messages
this.clearMessages($container);
},
/**
* Update total price
*
* @param {jQuery} $container Container element
*/
updateTotalPrice: function($container) {
const $checked = $container.find('.composable-product-checkbox:checked');
let total = 0;
$checked.each(function() {
const price = parseFloat($(this).data('price'));
if (!isNaN(price)) {
total += price;
}
});
const currencySymbol = $container.find('.total-price').data('currency');
$container.find('.calculated-total').text(currencySymbol + total.toFixed(2));
},
/**
* Add to cart
*
* @param {jQuery} $button Button element
*/
addToCart: function($button) {
const $container = $button.closest('.wc-composable-product-selector');
const $checked = $container.find('.composable-product-checkbox:checked');
const productId = $button.data('product-id');
// Validate selection
if ($checked.length === 0) {
this.showMessage($container, wcComposableProduct.i18n.min_items, 'error');
return;
}
// Collect selected product IDs
const selectedProducts = [];
$checked.each(function() {
selectedProducts.push($(this).val());
});
// Disable button
$button.prop('disabled', true).addClass('loading');
// Add to cart via AJAX
$.ajax({
url: wc_add_to_cart_params.wc_ajax_url.toString().replace('%%endpoint%%', 'add_to_cart'),
type: 'POST',
data: {
product_id: productId,
quantity: 1,
composable_products: selectedProducts
},
success: function(response) {
if (response.error) {
this.showMessage($container, response.error, 'error');
} else {
// Trigger WooCommerce event
$(document.body).trigger('added_to_cart', [response.fragments, response.cart_hash, $button]);
// Show success message
this.showMessage($container, 'Product added to cart!', 'success');
// Reset selection
setTimeout(function() {
$checked.prop('checked', false);
$container.find('.composable-product-item').removeClass('selected');
this.updateTotalPrice($container);
}.bind(this), 1500);
}
}.bind(this),
error: function() {
this.showMessage($container, 'An error occurred. Please try again.', 'error');
}.bind(this),
complete: function() {
$button.prop('disabled', false).removeClass('loading');
}
});
},
/**
* Show message
*
* @param {jQuery} $container Container element
* @param {string} message Message text
* @param {string} type Message type (error, success)
*/
showMessage: function($container, message, type) {
const $messages = $container.find('.composable-messages');
const $message = $('<div>')
.addClass('composable-message')
.addClass('composable-message-' + type)
.text(message);
$messages.empty().append($message);
// Auto-hide after 5 seconds
setTimeout(function() {
$message.fadeOut(function() {
$(this).remove();
});
}, 5000);
},
/**
* Clear messages
*
* @param {jQuery} $container Container element
*/
clearMessages: function($container) {
$container.find('.composable-messages').empty();
}
};
// Initialize on document ready
$(document).ready(function() {
ComposableProduct.init();
});
})(jQuery);

25
composer.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "magdev/wc-composable-product",
"description": "WooCommerce plugin for user composable products",
"type": "wordpress-plugin",
"license": "GPL-3.0-or-later",
"authors": [
{
"name": "Marco Graetsch",
"email": "marco@example.com"
}
],
"require": {
"php": ">=8.3",
"twig/twig": "^3.0"
},
"autoload": {
"psr-4": {
"WC_Composable_Product\\": "includes/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
}
}

View File

@@ -0,0 +1,190 @@
<?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. Leave empty to use global default.', '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'),
],
]);
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);
}
}

108
includes/Admin/Settings.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
/**
* Admin Settings
*
* @package WC_Composable_Product
*/
namespace WC_Composable_Product\Admin;
defined('ABSPATH') || exit;
/**
* Settings class
*/
class Settings extends \WC_Settings_Page {
/**
* Constructor
*/
public function __construct() {
$this->id = 'composable_products';
$this->label = __('Composable Products', 'wc-composable-product');
parent::__construct();
}
/**
* Get settings array
*
* @return array
*/
public function get_settings() {
$settings = [
[
'title' => __('Composable Products Settings', 'wc-composable-product'),
'type' => 'title',
'desc' => __('Configure default settings for composable products.', 'wc-composable-product'),
'id' => 'wc_composable_settings',
],
[
'title' => __('Default Selection Limit', 'wc-composable-product'),
'desc' => __('Default number of items customers can select.', 'wc-composable-product'),
'id' => 'wc_composable_default_limit',
'type' => 'number',
'default' => '5',
'custom_attributes' => [
'min' => '1',
'step' => '1',
],
'desc_tip' => true,
],
[
'title' => __('Default Pricing Mode', 'wc-composable-product'),
'desc' => __('How to calculate the price of composable products.', 'wc-composable-product'),
'id' => 'wc_composable_default_pricing',
'type' => 'select',
'default' => 'sum',
'options' => [
'sum' => __('Sum of selected products', 'wc-composable-product'),
'fixed' => __('Fixed price', 'wc-composable-product'),
],
'desc_tip' => true,
],
[
'title' => __('Show Product Images', 'wc-composable-product'),
'desc' => __('Display product images in the selection interface.', 'wc-composable-product'),
'id' => 'wc_composable_show_images',
'type' => 'checkbox',
'default' => 'yes',
],
[
'title' => __('Show Product Prices', 'wc-composable-product'),
'desc' => __('Display individual product prices in the selection interface.', 'wc-composable-product'),
'id' => 'wc_composable_show_prices',
'type' => 'checkbox',
'default' => 'yes',
],
[
'title' => __('Show Total Price', 'wc-composable-product'),
'desc' => __('Display the total price as customers make selections.', 'wc-composable-product'),
'id' => 'wc_composable_show_total',
'type' => 'checkbox',
'default' => 'yes',
],
[
'type' => 'sectionend',
'id' => 'wc_composable_settings',
],
];
return apply_filters('wc_composable_settings', $settings);
}
/**
* Output the settings
*/
public function output() {
$settings = $this->get_settings();
\WC_Admin_Settings::output_fields($settings);
}
/**
* Save settings
*/
public function save() {
$settings = $this->get_settings();
\WC_Admin_Settings::save_fields($settings);
}
}

181
includes/Cart_Handler.php Normal file
View File

@@ -0,0 +1,181 @@
<?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 {
/**
* Constructor
*/
public function __construct() {
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);
}
/**
* 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;
}
}
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;
}
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);
}
}
}
}
}

216
includes/Plugin.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
/**
* Main Plugin Class
*
* @package WC_Composable_Product
*/
namespace WC_Composable_Product;
defined('ABSPATH') || exit;
/**
* Main plugin class - Singleton pattern
*/
class Plugin {
/**
* The single instance of the class
*
* @var Plugin
*/
protected static $instance = null;
/**
* Twig environment
*
* @var \Twig\Environment
*/
private $twig = null;
/**
* Main Plugin Instance
*
* Ensures only one instance is loaded or can be loaded.
*
* @return Plugin
*/
public static function instance() {
if (is_null(self::$instance)) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->init_hooks();
$this->init_twig();
$this->includes();
}
/**
* Hook into WordPress and WooCommerce
*/
private function init_hooks() {
// Register product type
add_filter('product_type_selector', [$this, 'add_product_type']);
add_filter('woocommerce_product_class', [$this, 'product_class'], 10, 2);
// Enqueue scripts and styles
add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_scripts']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
// Admin settings
add_filter('woocommerce_get_settings_pages', [$this, 'add_settings_page']);
}
/**
* Initialize Twig template engine
*/
private function init_twig() {
$loader = new \Twig\Loader\FilesystemLoader(WC_COMPOSABLE_PRODUCT_PATH . 'templates');
$this->twig = new \Twig\Environment($loader, [
'cache' => WC_COMPOSABLE_PRODUCT_PATH . 'cache',
'auto_reload' => true,
'debug' => defined('WP_DEBUG') && WP_DEBUG,
]);
// Add WordPress functions to Twig
$this->twig->addFunction(new \Twig\TwigFunction('__', function($text) {
return __($text, 'wc-composable-product');
}));
$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'));
}
/**
* Include required files
*/
private function includes() {
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Admin/Settings.php';
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Admin/Product_Data.php';
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Product_Type.php';
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Cart_Handler.php';
require_once WC_COMPOSABLE_PRODUCT_PATH . 'includes/Product_Selector.php';
// Initialize components
new Admin\Product_Data();
new Cart_Handler();
}
/**
* Add composable product type to selector
*
* @param array $types Product types
* @return array
*/
public function add_product_type($types) {
$types['composable'] = __('Composable product', 'wc-composable-product');
return $types;
}
/**
* Use custom product class for composable products
*
* @param string $classname Product class name
* @param string $product_type Product type
* @return string
*/
public function product_class($classname, $product_type) {
if ($product_type === 'composable') {
$classname = 'WC_Composable_Product\Product_Type';
}
return $classname;
}
/**
* Enqueue frontend scripts and styles
*/
public function enqueue_frontend_scripts() {
if (is_product()) {
wp_enqueue_style(
'wc-composable-product',
WC_COMPOSABLE_PRODUCT_URL . 'assets/css/frontend.css',
[],
WC_COMPOSABLE_PRODUCT_VERSION
);
wp_enqueue_script(
'wc-composable-product',
WC_COMPOSABLE_PRODUCT_URL . 'assets/js/frontend.js',
['jquery'],
WC_COMPOSABLE_PRODUCT_VERSION,
true
);
wp_localize_script('wc-composable-product', 'wcComposableProduct', [
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('wc_composable_product_nonce'),
'i18n' => [
'select_items' => __('Please select items', 'wc-composable-product'),
'max_items' => __('Maximum items selected', 'wc-composable-product'),
'min_items' => __('Please select at least one item', 'wc-composable-product'),
],
]);
}
}
/**
* Enqueue admin scripts and styles
*/
public function enqueue_admin_scripts($hook) {
if ('post.php' === $hook || 'post-new.php' === $hook) {
global $post_type;
if ('product' === $post_type) {
wp_enqueue_style(
'wc-composable-product-admin',
WC_COMPOSABLE_PRODUCT_URL . 'assets/css/admin.css',
[],
WC_COMPOSABLE_PRODUCT_VERSION
);
wp_enqueue_script(
'wc-composable-product-admin',
WC_COMPOSABLE_PRODUCT_URL . 'assets/js/admin.js',
['jquery', 'wc-admin-product-meta-boxes'],
WC_COMPOSABLE_PRODUCT_VERSION,
true
);
}
}
}
/**
* Add settings page to WooCommerce
*
* @param array $settings WooCommerce settings pages
* @return array
*/
public function add_settings_page($settings) {
$settings[] = new Admin\Settings();
return $settings;
}
/**
* Get Twig environment
*
* @return \Twig\Environment
*/
public function get_twig() {
return $this->twig;
}
/**
* Render a Twig template
*
* @param string $template Template name
* @param array $context Template variables
* @return string
*/
public function render_template($template, $context = []) {
return $this->twig->render($template, $context);
}
}

View File

@@ -0,0 +1,65 @@
<?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';
// Prepare product data for template
$products_data = [];
foreach ($available_products as $available_product) {
$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(),
];
}
$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(),
'currency_symbol' => get_woocommerce_currency_symbol(),
];
// Render template
$plugin = Plugin::instance();
echo $plugin->render_template('product-selector.twig', $context);
}
}

213
includes/Product_Type.php Normal file
View File

@@ -0,0 +1,213 @@
<?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',
'tax_query' => [],
];
// Exclude composable products from selection
$args['meta_query'] = [
[
'key' => '_product_type',
'value' => 'composable',
'compare' => '!=',
],
];
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 && $product->is_in_stock() && $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;
}
}

View File

@@ -0,0 +1,199 @@
# Copyright (C) 2024 Marco Graetsch
# This file is distributed under the GPL v3 or later.
msgid ""
msgstr ""
"Project-Id-Version: WooCommerce Composable Products 1.0.0\n"
"Report-Msgid-Bugs-To: https://github.com/magdev/wc-composable-product/issues\n"
"POT-Creation-Date: 2024-12-31 00:00+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: WP-CLI\n"
#: wc-composable-product.php
msgid "WooCommerce Composable Products requires WooCommerce to be installed and active."
msgstr ""
#: wc-composable-product.php
msgid "This plugin requires WooCommerce to be installed and active."
msgstr ""
#: wc-composable-product.php
msgid "Plugin Activation Error"
msgstr ""
#: includes/Admin/Settings.php
msgid "Composable Products"
msgstr ""
#: includes/Admin/Settings.php
msgid "Composable Products Settings"
msgstr ""
#: includes/Admin/Settings.php
msgid "Configure default settings for composable products."
msgstr ""
#: includes/Admin/Settings.php
msgid "Default Selection Limit"
msgstr ""
#: includes/Admin/Settings.php
msgid "Default number of items customers can select."
msgstr ""
#: includes/Admin/Settings.php
msgid "Default Pricing Mode"
msgstr ""
#: includes/Admin/Settings.php
msgid "How to calculate the price of composable products."
msgstr ""
#: includes/Admin/Settings.php
msgid "Sum of selected products"
msgstr ""
#: includes/Admin/Settings.php
msgid "Fixed price"
msgstr ""
#: includes/Admin/Settings.php
msgid "Show Product Images"
msgstr ""
#: includes/Admin/Settings.php
msgid "Display product images in the selection interface."
msgstr ""
#: includes/Admin/Settings.php
msgid "Show Product Prices"
msgstr ""
#: includes/Admin/Settings.php
msgid "Display individual product prices in the selection interface."
msgstr ""
#: includes/Admin/Settings.php
msgid "Show Total Price"
msgstr ""
#: includes/Admin/Settings.php
msgid "Display the total price as customers make selections."
msgstr ""
#: includes/Plugin.php
msgid "Composable product"
msgstr ""
#: includes/Product_Selector.php, templates/product-selector.twig
msgid "Select Your Products"
msgstr ""
#: templates/product-selector.twig
msgid "Choose up to"
msgstr ""
#: templates/product-selector.twig
msgid "items from the selection below."
msgstr ""
#: templates/product-selector.twig
msgid "Total Price:"
msgstr ""
#: templates/product-selector.twig
msgid "Add to Cart"
msgstr ""
#: includes/Cart_Handler.php
msgid "Please select at least one product."
msgstr ""
#: includes/Cart_Handler.php
msgid "You can select a maximum of %d products."
msgstr ""
#: includes/Cart_Handler.php
msgid "One or more selected products are not available."
msgstr ""
#: includes/Cart_Handler.php
msgid "Selected Products"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Composable Options"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Selection Limit"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Maximum number of items customers can select. Leave empty to use global default."
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Pricing Mode"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "How to calculate the price. Leave empty to use global default."
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Use global default"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Selection Criteria"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "How to select available products."
msgstr ""
#: includes/Admin/Product_Data.php
msgid "By Category"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "By Tag"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "By SKU"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Select Categories"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Select product categories to include."
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Select Tags"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Select product tags to include."
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Product SKUs"
msgstr ""
#: includes/Admin/Product_Data.php
msgid "Enter product SKUs separated by commas."
msgstr ""
#: includes/Admin/Product_Data.php
msgid "SKU-1, SKU-2, SKU-3"
msgstr ""

View File

@@ -0,0 +1,65 @@
{# Product Selector Template #}
<div class="wc-composable-product-selector" data-product-id="{{ product_id }}" data-selection-limit="{{ selection_limit }}" data-pricing-mode="{{ pricing_mode }}" data-fixed-price="{{ fixed_price }}">
<div class="composable-header">
<h3>{{ __('Select Your Products') }}</h3>
<p class="selection-info">
{{ __('Choose up to') }} <strong>{{ selection_limit }}</strong> {{ __('items from the selection below.') }}
</p>
</div>
<div class="composable-products-grid">
{% for product in products %}
<div class="composable-product-item" data-product-id="{{ product.id }}" data-price="{{ product.price }}">
<div class="product-item-inner">
<label class="product-item-label">
<input type="checkbox"
name="composable_products[]"
value="{{ product.id }}"
class="composable-product-checkbox"
data-product-id="{{ product.id }}"
data-price="{{ product.price }}">
{% if show_images and product.image_url %}
<div class="product-item-image">
<img src="{{ product.image_url }}" alt="{{ product.name|esc_attr }}">
</div>
{% endif %}
<div class="product-item-details">
<h4 class="product-item-name">{{ product.name|esc_html }}</h4>
{% if show_prices %}
<div class="product-item-price">
{{ product.price_html|raw }}
</div>
{% endif %}
</div>
<span class="product-item-checkmark"></span>
</label>
</div>
</div>
{% endfor %}
</div>
{% if show_total %}
<div class="composable-total">
<div class="total-label">{{ __('Total Price:') }}</div>
<div class="total-price" data-currency="{{ currency_symbol }}">
{% if pricing_mode == 'fixed' %}
{{ currency_symbol }}{{ fixed_price }}
{% else %}
<span class="calculated-total">{{ currency_symbol }}0.00</span>
{% endif %}
</div>
</div>
{% endif %}
<div class="composable-actions">
<button type="button" class="button alt composable-add-to-cart" data-product-id="{{ product_id }}">
{{ __('Add to Cart') }}
</button>
<div class="composable-messages"></div>
</div>
</div>

79
wc-composable-product.php Normal file
View File

@@ -0,0 +1,79 @@
<?php
/**
* Plugin Name: WooCommerce Composable Products
* Plugin URI: https://github.com/magdev/wc-composable-product
* Description: Create composable products where customers select a limited number of items from a configurable set
* Version: 1.0.0
* Author: Marco Graetsch
* Author URI: https://example.com
* License: GPL v3 or later
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
* Text Domain: wc-composable-product
* Domain Path: /languages
* Requires at least: 6.0
* Requires PHP: 8.3
* WC requires at least: 8.0
* WC tested up to: 10.0
*/
defined('ABSPATH') || exit;
// Define plugin constants
define('WC_COMPOSABLE_PRODUCT_VERSION', '1.0.0');
define('WC_COMPOSABLE_PRODUCT_FILE', __FILE__);
define('WC_COMPOSABLE_PRODUCT_PATH', plugin_dir_path(__FILE__));
define('WC_COMPOSABLE_PRODUCT_URL', plugin_dir_url(__FILE__));
define('WC_COMPOSABLE_PRODUCT_BASENAME', plugin_basename(__FILE__));
// Load Composer autoloader
if (file_exists(WC_COMPOSABLE_PRODUCT_PATH . 'vendor/autoload.php')) {
require_once WC_COMPOSABLE_PRODUCT_PATH . 'vendor/autoload.php';
}
/**
* Check if WooCommerce is active
*/
function wc_composable_product_check_woocommerce() {
if (!class_exists('WooCommerce')) {
add_action('admin_notices', function() {
?>
<div class="notice notice-error">
<p><?php esc_html_e('WooCommerce Composable Products requires WooCommerce to be installed and active.', 'wc-composable-product'); ?></p>
</div>
<?php
});
return false;
}
return true;
}
/**
* Initialize the plugin
*/
function wc_composable_product_init() {
if (!wc_composable_product_check_woocommerce()) {
return;
}
// Load text domain
load_plugin_textdomain('wc-composable-product', false, dirname(WC_COMPOSABLE_PRODUCT_BASENAME) . '/languages');
// Initialize main plugin class
WC_Composable_Product\Plugin::instance();
}
add_action('plugins_loaded', 'wc_composable_product_init');
/**
* Activation hook
*/
function wc_composable_product_activate() {
if (!class_exists('WooCommerce')) {
deactivate_plugins(WC_COMPOSABLE_PRODUCT_BASENAME);
wp_die(
esc_html__('This plugin requires WooCommerce to be installed and active.', 'wc-composable-product'),
esc_html__('Plugin Activation Error', 'wc-composable-product'),
array('back_link' => true)
);
}
}
register_activation_hook(__FILE__, 'wc_composable_product_activate');