commit 1edb0be3d95cfc8cc731cbc775ed9d75cbdd1bd9 Author: magdev Date: Wed Dec 31 00:38:29 2025 +0100 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 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');