From 1edb0be3d95cfc8cc731cbc775ed9d75cbdd1bd9 Mon Sep 17 00:00:00 2001 From: magdev Date: Wed, 31 Dec 2025 00:38:29 +0100 Subject: [PATCH] Initial implementation of WooCommerce Composable Products plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 26 ++ CHANGELOG.md | 34 +++ CLAUDE.md | 92 +++++++ IMPLEMENTATION.md | 406 ++++++++++++++++++++++++++++ INSTALL.md | 150 ++++++++++ LICENSE | 22 ++ README.md | 77 ++++++ assets/css/admin.css | 43 +++ assets/css/frontend.css | 201 ++++++++++++++ assets/js/admin.js | 50 ++++ assets/js/frontend.js | 186 +++++++++++++ composer.json | 25 ++ includes/Admin/Product_Data.php | 190 +++++++++++++ includes/Admin/Settings.php | 108 ++++++++ includes/Cart_Handler.php | 181 +++++++++++++ includes/Plugin.php | 216 +++++++++++++++ includes/Product_Selector.php | 65 +++++ includes/Product_Type.php | 213 +++++++++++++++ languages/wc-composable-product.pot | 199 ++++++++++++++ templates/product-selector.twig | 65 +++++ wc-composable-product.php | 79 ++++++ 21 files changed, 2628 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 IMPLEMENTATION.md create mode 100644 INSTALL.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/css/admin.css create mode 100644 assets/css/frontend.css create mode 100644 assets/js/admin.js create mode 100644 assets/js/frontend.js create mode 100644 composer.json create mode 100644 includes/Admin/Product_Data.php create mode 100644 includes/Admin/Settings.php create mode 100644 includes/Cart_Handler.php create mode 100644 includes/Plugin.php create mode 100644 includes/Product_Selector.php create mode 100644 includes/Product_Type.php create mode 100644 languages/wc-composable-product.pot create mode 100644 templates/product-selector.twig create mode 100644 wc-composable-product.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45d0b21 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fb94560 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..11c7b45 --- /dev/null +++ b/CLAUDE.md @@ -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! diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..858586b --- /dev/null +++ b/IMPLEMENTATION.md @@ -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) diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..dae9bc0 --- /dev/null +++ b/INSTALL.md @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1ca7d1b --- /dev/null +++ b/LICENSE @@ -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 . + +--- + +For the full text of the GPL v3 license, visit: +https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..093025e --- /dev/null +++ b/README.md @@ -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. diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..9dd102f --- /dev/null +++ b/assets/css/admin.css @@ -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; +} diff --git a/assets/css/frontend.css b/assets/css/frontend.css new file mode 100644 index 0000000..dfa6206 --- /dev/null +++ b/assets/css/frontend.css @@ -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; + } +} diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..e08e9b1 --- /dev/null +++ b/assets/js/admin.js @@ -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); diff --git a/assets/js/frontend.js b/assets/js/frontend.js new file mode 100644 index 0000000..470044c --- /dev/null +++ b/assets/js/frontend.js @@ -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 = $('
') + .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); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5c245f8 --- /dev/null +++ b/composer.json @@ -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 + } +} diff --git a/includes/Admin/Product_Data.php b/includes/Admin/Product_Data.php new file mode 100644 index 0000000..ec12913 --- /dev/null +++ b/includes/Admin/Product_Data.php @@ -0,0 +1,190 @@ + __('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 '
'; + + 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 '
'; + } + } + + /** + * Add product data panel + */ + public function add_product_data_panel() { + global $post; + ?> + + 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); + } +} diff --git a/includes/Cart_Handler.php b/includes/Cart_Handler.php new file mode 100644 index 0000000..e1d8866 --- /dev/null +++ b/includes/Cart_Handler.php @@ -0,0 +1,181 @@ +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); + } + } + } + } +} diff --git a/includes/Plugin.php b/includes/Plugin.php new file mode 100644 index 0000000..4662745 --- /dev/null +++ b/includes/Plugin.php @@ -0,0 +1,216 @@ +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); + } +} diff --git a/includes/Product_Selector.php b/includes/Product_Selector.php new file mode 100644 index 0000000..b200a1c --- /dev/null +++ b/includes/Product_Selector.php @@ -0,0 +1,65 @@ +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); + } +} diff --git a/includes/Product_Type.php b/includes/Product_Type.php new file mode 100644 index 0000000..55c1037 --- /dev/null +++ b/includes/Product_Type.php @@ -0,0 +1,213 @@ +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; + } +} diff --git a/languages/wc-composable-product.pot b/languages/wc-composable-product.pot new file mode 100644 index 0000000..30dd7d8 --- /dev/null +++ b/languages/wc-composable-product.pot @@ -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 \n" +"Language-Team: LANGUAGE \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 "" diff --git a/templates/product-selector.twig b/templates/product-selector.twig new file mode 100644 index 0000000..f376e0c --- /dev/null +++ b/templates/product-selector.twig @@ -0,0 +1,65 @@ +{# Product Selector Template #} +
+ +
+

{{ __('Select Your Products') }}

+

+ {{ __('Choose up to') }} {{ selection_limit }} {{ __('items from the selection below.') }} +

+
+ +
+ {% for product in products %} +
+
+ +
+
+ {% endfor %} +
+ + {% if show_total %} +
+
{{ __('Total Price:') }}
+
+ {% if pricing_mode == 'fixed' %} + {{ currency_symbol }}{{ fixed_price }} + {% else %} + {{ currency_symbol }}0.00 + {% endif %} +
+
+ {% endif %} + +
+ +
+
+
diff --git a/wc-composable-product.php b/wc-composable-product.php new file mode 100644 index 0000000..e7aec01 --- /dev/null +++ b/wc-composable-product.php @@ -0,0 +1,79 @@ + +
+

+
+ true) + ); + } +} +register_activation_hook(__FILE__, 'wc_composable_product_activate');