diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 1cdf242..b866476 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -71,4 +71,42 @@ .yoone-bundle-item-card .item-quantity input { width: 80px; text-align: center; +} + +/* 购物车:混装容器与子项目的分组样式 */ +.woocommerce-cart .cart_item.yoone-bundle-container td { + border-top: 2px solid #ececec; +} + +.woocommerce-cart .cart_item.yoone-bundle-child td { + padding-top: 6px; + padding-bottom: 6px; +} + +.woocommerce-cart .cart_item.yoone-bundle-child .product-name { + position: relative; + padding-left: 18px; + font-size: 0.95em; +} + +.woocommerce-cart .cart_item.yoone-bundle-child .product-name::before { + content: '↳'; + position: absolute; + left: 0; + color: #9aa0a6; +} + +.woocommerce-cart .cart_item.yoone-bundle-child .product-thumbnail img { + max-width: 32px; + height: auto; + opacity: 0.9; +} + +.woocommerce-cart .cart_item.yoone-bundle-child { + color: #444; +} + +.woocommerce-cart .cart_item.yoone-bundle-child .product-price, +.woocommerce-cart .cart_item.yoone-bundle-child .product-subtotal { + font-size: 0.93em; } \ No newline at end of file diff --git a/assets/js/admin.js b/assets/js/admin.js index 5b4ea54..ad47c01 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -1,6 +1,7 @@ /* Yoone Product Bundles - Admin dynamic grouping terms * When switching grouping taxonomy (category/tag), refresh the terms select list accordingly. */ +// Wrap ALL logic inside the jQuery IIFE to keep variables scoped and avoid ReferenceErrors (function($){ $(function(){ var $tax = $('#yoone_bundle_group_taxonomy'); @@ -66,10 +67,6 @@ $productSelect.prop('disabled', isAll); $addAllBtn.prop('disabled', isAll); } - if ($selectMode.length) { - updateProductsListState(); - $selectMode.on('change', updateProductsListState); - } // Add all simple products to the products select if ($addAllBtn.length && $productSelect.length) { @@ -117,8 +114,7 @@ $productSelect.val([]).trigger('change'); }); } - }); -})(jQuery); + function renderEffectiveList(items, count) { var $list = $('#yoone_effective_products_list'); var $count = $('.yoone-effective-count'); @@ -174,4 +170,6 @@ $refresh.on('click', function(){ recomputeEffective(); }); - } \ No newline at end of file + } + }); +})(jQuery); \ No newline at end of file diff --git a/assets/js/frontend.js b/assets/js/frontend.js index dfd2590..c4148da 100644 --- a/assets/js/frontend.js +++ b/assets/js/frontend.js @@ -4,6 +4,15 @@ $(function() { var minQty = 0; var wrapper = $('.yoone-bundle-form'); + if (!wrapper.length) return; + + // 价格格式 + var currencySymbol = wrapper.data('currency-symbol') || ''; + var priceDecimals = parseInt(wrapper.data('price-decimals'), 10); + var decSep = wrapper.data('decimal-separator') || '.'; + var thouSep = wrapper.data('thousand-separator') || ','; + var discountType = wrapper.data('discount-type') || 'none'; + var discountAmount = parseFloat(wrapper.data('discount-amount') || '0'); // 从 DOM 中获取最小数量 var minText = wrapper.find('.yoone-bundle-min').text(); @@ -14,10 +23,24 @@ } } + function formatMoney(n) { + var s = (typeof n === 'number') ? n : parseFloat(n || 0); + if (isNaN(s)) s = 0; + var fixed = s.toFixed(isNaN(priceDecimals) ? 2 : priceDecimals); + var parts = fixed.split('.'); + var intPart = parts[0]; + var fracPart = parts[1] ? decSep + parts[1] : ''; + // thousands grouping + var rgx = /\B(?=(\d{3})+(?!\d))/g; + intPart = intPart.replace(rgx, thouSep); + return currencySymbol + intPart + fracPart; + } + function updateState() { var total = 0; wrapper.find('.yoone-bundle-qty').each(function() { var v = parseInt($(this).val(), 10); + var unit = parseFloat($(this).data('unit-price') || '0'); if (!isNaN(v) && v > 0) { total += v; } @@ -33,6 +56,26 @@ } else { btn.prop('disabled', true); } + + // 计算价格总计与折扣后的总计 + var rawSum = 0; + wrapper.find('.yoone-bundle-qty').each(function() { + var qty = parseInt($(this).val(), 10); + var unit = parseFloat($(this).data('unit-price') || '0'); + if (!isNaN(qty) && qty > 0 && !isNaN(unit) && unit > 0) { + rawSum += qty * unit; + } + }); + wrapper.find('.yoone-bundle-total-raw-value').text(formatMoney(rawSum)); + if (discountType !== 'none' && discountAmount > 0) { + var discounted = rawSum; + if (discountType === 'percent') { + discounted = rawSum * (1 - discountAmount / 100.0); + } else if (discountType === 'fixed') { + discounted = Math.max(0, rawSum - discountAmount); + } + wrapper.find('.yoone-bundle-total-discounted-value').text(formatMoney(discounted)); + } } // 绑定事件 diff --git a/includes/admin/class-yoone-product-bundles-admin.php b/includes/admin/class-yoone-product-bundles-admin.php index 36504c0..059ffe1 100644 --- a/includes/admin/class-yoone-product-bundles-admin.php +++ b/includes/admin/class-yoone-product-bundles-admin.php @@ -35,7 +35,7 @@ class Yoone_Product_Bundles_Admin { public function add_product_data_tab($tabs) { $tabs['yoone_bundle'] = array( - 'label' => __('Mix and Match', 'yoone-product-bundles'), + 'label' => __('Product Bundle (Yoone Bundle)', 'yoone-product-bundles'), 'target' => 'yoone_bundle_data', 'class' => array('show_if_yoone_bundle'), // Only show for 'yoone_bundle' type 'priority' => 70, @@ -64,9 +64,12 @@ class Yoone_Product_Bundles_Admin { // 供渲染“有效产品总数”的计算结果 $effective_allowed = isset($config['allowed_products']) ? (array) $config['allowed_products'] : array(); $min_qty = $config['min_qty']; + $edit_in_cart = isset($config['edit_in_cart']) ? $config['edit_in_cart'] : 'no'; $select_mode = isset($config['select_mode']) ? $config['select_mode'] : 'include'; $group_taxonomy = isset($config['group_taxonomy']) ? $config['group_taxonomy'] : 'product_cat'; $group_terms = isset($config['group_terms']) ? (array) $config['group_terms'] : array(); + $discount_type = isset($config['discount_type']) ? $config['discount_type'] : 'none'; + $discount_amount= isset($config['discount_amount']) ? $config['discount_amount'] : 0; echo '
'; wp_nonce_field('yoone-bundle-admin-nonce', 'yoone_bundle_admin_nonce_field'); @@ -89,38 +92,36 @@ class Yoone_Product_Bundles_Admin { 'value' => $select_mode, )); - // Allowed/Excluded products: use Woo's product search (select2), multiple - if ($select_mode !== 'all') { - $list_label = ($select_mode === 'exclude') - ? esc_html__('Excluded Products', 'yoone-product-bundles') - : esc_html__('Included Products', 'yoone-product-bundles'); - echo '

'; - echo ''; - echo ' '; - echo ''; - // Search only for products, not variations, to avoid errors - echo ''; + // 显示“原始配置列表”作为可编辑项(exclude 模式记为排除列表,include 模式记为包含列表) + if (! empty($allowed_raw)) { + foreach ($allowed_raw as $pid) { + $p = wc_get_product($pid); + if ($p) { + printf('', $pid, esc_html($p->get_formatted_name())); } } - echo ''; - // Dynamic description based on selection mode - if ($select_mode === 'include') { - echo '' . esc_html__('Simple products only. In Include mode, ONLY the listed products will be available.', 'yoone-product-bundles') . ''; - } else { // exclude - echo '' . esc_html__('Simple products only. In Exclude mode, ALL published simple products are available EXCEPT the listed ones.', 'yoone-product-bundles') . ''; - } - echo '

'; - } else { - // All 模式:不显示选择框,仅提示说明 - echo ''; } + echo ''; + // Dynamic description based on selection mode + if ($select_mode === 'include') { + echo '' . esc_html__('Simple products only. In Include mode, ONLY the listed products will be available.', 'yoone-product-bundles') . ''; + } else { // exclude or all (label will be updated via JS when switching) + echo '' . esc_html__('Simple products only. In Exclude mode, ALL published simple products are available EXCEPT the listed ones.', 'yoone-product-bundles') . ''; + } + echo '

'; // 只读展示:有效产品列表与总数(基于当前配置推导) echo '
'; @@ -155,6 +156,40 @@ class Yoone_Product_Bundles_Admin { 'custom_attributes' => array('min' => '0'), )); + // Enable editing bundle in cart + woocommerce_wp_checkbox(array( + 'id' => 'yoone_bundle_edit_in_cart', + 'label' => __('Enable "Edit in Cart"', 'yoone-product-bundles'), + 'desc_tip' => true, + 'description' => __('Allow customers to change bundle child item quantities (and optionally remove child items) on the Cart page.', 'yoone-product-bundles'), + 'value' => $edit_in_cart === 'yes' ? 'yes' : 'no', + )); + + // Bundle discount type + woocommerce_wp_select(array( + 'id' => 'yoone_bundle_discount_type', + 'label' => __('Bundle Discount Type', 'yoone-product-bundles'), + 'options' => array( + 'none' => __('None', 'yoone-product-bundles'), + 'percent' => __('Percentage (%)', 'yoone-product-bundles'), + 'fixed' => __('Fixed Amount', 'yoone-product-bundles'), + ), + 'value' => $discount_type, + 'desc_tip' => true, + 'description' => __('Apply a discount to the total price of selected bundle items.', 'yoone-product-bundles'), + )); + + // Bundle discount amount + woocommerce_wp_text_input(array( + 'id' => 'yoone_bundle_discount_amount', + 'label' => __('Bundle Discount Amount', 'yoone-product-bundles'), + 'type' => 'number', + 'value' => $discount_amount, + 'custom_attributes' => array('step' => '0.01', 'min' => '0'), + 'desc_tip' => true, + 'description' => __('If type is Percentage, enter a number like 10 for 10%. If Fixed, enter the currency amount to subtract from the bundle total.', 'yoone-product-bundles'), + )); + // Grouping taxonomy (product_cat or product_tag) woocommerce_wp_select(array( 'id' => 'yoone_bundle_group_taxonomy', @@ -187,7 +222,7 @@ class Yoone_Product_Bundles_Admin { public function save_product_meta($post_id) { // 无论当前 product 对象类型为何,只要提交了我们的字段,就进行保存。 // 这可以避免在首次切换产品类型时由于保存顺序问题导致配置未写入。 - $has_fields = isset($_POST['yoone_bundle_allowed_products']) || isset($_POST['yoone_bundle_min_quantity']) || isset($_POST['yoone_bundle_group_taxonomy']) || isset($_POST['yoone_bundle_group_terms']) || isset($_POST['yoone_bundle_select_mode']); + $has_fields = isset($_POST['yoone_bundle_allowed_products']) || isset($_POST['yoone_bundle_min_quantity']) || isset($_POST['yoone_bundle_group_taxonomy']) || isset($_POST['yoone_bundle_group_terms']) || isset($_POST['yoone_bundle_select_mode']) || isset($_POST['yoone_bundle_edit_in_cart']) || isset($_POST['yoone_bundle_discount_type']) || isset($_POST['yoone_bundle_discount_amount']); if (! $has_fields) return; // 保存 allowed products @@ -210,6 +245,20 @@ class Yoone_Product_Bundles_Admin { $min_qty = isset($_POST['yoone_bundle_min_quantity']) ? absint($_POST['yoone_bundle_min_quantity']) : 0; update_post_meta($post_id, Yoone_Product_Bundles::META_MIN_QTY, max(0, $min_qty)); + // 保存“编辑购物车”开关 + $edit_in_cart = isset($_POST['yoone_bundle_edit_in_cart']) ? 'yes' : 'no'; + update_post_meta($post_id, Yoone_Product_Bundles::META_EDIT_IN_CART, $edit_in_cart); + + // 保存折扣 + $discount_type = isset($_POST['yoone_bundle_discount_type']) ? sanitize_text_field(wp_unslash($_POST['yoone_bundle_discount_type'])) : 'none'; + if (!in_array($discount_type, array('none','percent','fixed'), true)) { + $discount_type = 'none'; + } + $discount_amount = isset($_POST['yoone_bundle_discount_amount']) ? floatval($_POST['yoone_bundle_discount_amount']) : 0; + if ($discount_amount < 0) $discount_amount = 0.0; + update_post_meta($post_id, Yoone_Product_Bundles::META_DISCOUNT_TYPE, $discount_type); + update_post_meta($post_id, Yoone_Product_Bundles::META_DISCOUNT_AMOUNT, $discount_amount); + // 保存选择模式(include | exclude | all) $select_mode = isset($_POST['yoone_bundle_select_mode']) ? sanitize_text_field($_POST['yoone_bundle_select_mode']) : 'include'; $select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include'; diff --git a/includes/class-yoone-product-bundles.php b/includes/class-yoone-product-bundles.php index b4ae1fa..6cb01d1 100644 --- a/includes/class-yoone-product-bundles.php +++ b/includes/class-yoone-product-bundles.php @@ -10,6 +10,11 @@ class Yoone_Product_Bundles { // Post meta keys for configuration const META_ALLOWED_PRODUCTS = '_yoone_bundle_allowed_products'; // array const META_MIN_QTY = '_yoone_bundle_min_quantity'; // int + // 是否允许在购物车中编辑混装(移除子项、调整数量等) + const META_EDIT_IN_CART = '_yoone_bundle_edit_in_cart'; // 'yes' | 'no' + // Bundle 折扣设置 + const META_DISCOUNT_TYPE = '_yoone_bundle_discount_type'; // 'none' | 'percent' | 'fixed' + const META_DISCOUNT_AMOUNT = '_yoone_bundle_discount_amount'; // float // 选择模式:include(仅包含列表)、exclude(排除列表,其余全部允许)、all(全部 simple 产品) const META_SELECT_MODE = '_yoone_bundle_select_mode'; // 'include' | 'exclude' | 'all' // 旧版:仅支持分类(product_cat)。 @@ -38,7 +43,7 @@ class Yoone_Product_Bundles { * Add "Mix and Match" to the "Product Type" dropdown in the admin. */ public function register_product_type_in_selector($types) { - $types[self::TYPE] = __('Mix and Match (Yoone Bundle)', 'yoone-product-bundles'); + $types[self::TYPE] = __('Product Bundle (Yoone Bundle)', 'yoone-product-bundles'); return $types; } @@ -63,6 +68,12 @@ class Yoone_Product_Bundles { $pid = $product->get_id(); $allowed = get_post_meta($pid, self::META_ALLOWED_PRODUCTS, true); $min = absint(get_post_meta($pid, self::META_MIN_QTY, true)); + $edit_in_cart = get_post_meta($pid, self::META_EDIT_IN_CART, true); + $edit_in_cart = ($edit_in_cart === 'yes') ? 'yes' : 'no'; + $discount_type = get_post_meta($pid, self::META_DISCOUNT_TYPE, true); + $discount_type = in_array($discount_type, array('none','percent','fixed'), true) ? $discount_type : 'none'; + $discount_amount = floatval(get_post_meta($pid, self::META_DISCOUNT_AMOUNT, true)); + if ($discount_amount < 0) $discount_amount = 0.0; $select_mode = get_post_meta($pid, self::META_SELECT_MODE, true); $select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include'; // 读取新的分组设置 @@ -110,6 +121,9 @@ class Yoone_Product_Bundles { return array( 'allowed_products' => $allowed, 'min_qty' => max(0, $min), + 'edit_in_cart' => $edit_in_cart, + 'discount_type' => $discount_type, + 'discount_amount' => $discount_amount, 'select_mode' => $select_mode, 'group_taxonomy' => $group_tax, 'group_terms' => $group_terms, diff --git a/includes/frontend/class-yoone-product-bundles-frontend.php b/includes/frontend/class-yoone-product-bundles-frontend.php index 0dee662..d71993e 100644 --- a/includes/frontend/class-yoone-product-bundles-frontend.php +++ b/includes/frontend/class-yoone-product-bundles-frontend.php @@ -35,11 +35,30 @@ class Yoone_Product_Bundles_Frontend { // 在购物车中,显示子项目所属的混装产品 add_filter('woocommerce_get_item_data', array($this, 'display_child_bundle_link'), 10, 2); + // 为购物车行添加分组样式类:容器与子项 + add_filter('woocommerce_cart_item_class', array($this, 'add_cart_item_group_classes'), 10, 3); + + // 在购物车中为混装容器显示聚合价格(不再显示 0 元),并展示折扣节省信息 + add_filter('woocommerce_cart_item_price', array($this, 'render_container_cart_price'), 20, 3); + add_filter('woocommerce_cart_item_subtotal', array($this, 'render_container_cart_subtotal'), 20, 3); + // 为混装产品页面添加 body class,以便于样式化 add_filter('body_class', array($this, 'filter_body_class')); - // 用我们的自定义表单替换默认的“添加到购物车”按钮 + // 统一覆盖 Woo 的价格 HTML:当订阅组件或其它部件尝试通过 get_price_html() 渲染价格时,避免显示 $0.00 + add_filter('woocommerce_get_price_html', array($this, 'filter_yoone_bundle_price_html'), 10, 2); + + // 根据当前购物车中的混装子项,动态添加“Bundle Discount”费用(负费用) + add_action('woocommerce_cart_calculate_fees', array($this, 'apply_bundle_discounts'), 20, 1); + + // 在单品页尽早移除 APFS(All Products for Subscriptions)对按钮文案的有问题的过滤器,避免致命错误。 + // 该插件在某些 Woo 版本上将 'woocommerce_product_single_add_to_cart_text' 过滤器声明为接收2个参数, + // 但实际只传递1个参数,导致 ArgumentCountError。 + add_action('woocommerce_before_single_product', array($this, 'remove_conflicting_apfs_add_to_cart_text_filter'), 1); + + // 隐藏默认价格,改为说明语;用我们的自定义表单替换默认的“添加到购物车”按钮 add_action('woocommerce_single_product_summary', array($this, 'remove_default_add_to_cart_for_bundle'), 29); + add_action('woocommerce_single_product_summary', array($this, 'replace_single_price_for_bundle'), 8); add_action('woocommerce_single_product_summary', array($this, 'render_bundle_add_to_cart_form'), 30); } @@ -58,6 +77,10 @@ class Yoone_Product_Bundles_Frontend { // 1. 验证 $config = Yoone_Product_Bundles::get_bundle_config($product); $min_qty = max(1, absint($config['min_qty'])); + $config_edit_in_cart = isset($config['edit_in_cart']) && $config['edit_in_cart'] === 'yes'; + // 前端表单可显式传递是否允许在购物车编辑;否则以产品配置为准 + $posted_edit_in_cart = isset($_POST['yoone_bundle_edit_in_cart']) ? (bool) $_POST['yoone_bundle_edit_in_cart'] : null; + $edit_in_cart = is_null($posted_edit_in_cart) ? $config_edit_in_cart : (bool) $posted_edit_in_cart; $components = !empty($_POST['yoone_bundle_components']) ? (array) $_POST['yoone_bundle_components'] : array(); $total_qty = 0; @@ -79,15 +102,37 @@ class Yoone_Product_Bundles_Frontend { try { $bundle_container_id = uniqid('bundle_'); + // 如果 APFS 可用,从请求中读取选中的订阅方案,并在容器及子项目上附加 wcsatt_data。 + // 注意:部分环境可能存在类名,但方法签名不同;为防止致命错误,需检测方法是否存在。 + $wcsatt_data = array(); + if (class_exists('WCS_ATT_Product_Schemes')) { + $posted_scheme_key = null; + if (method_exists('WCS_ATT_Product_Schemes', 'get_posted_subscription_scheme')) { + // APFS 正常方法:根据当前产品读取提交的订阅方案键。 + $posted_scheme_key = WCS_ATT_Product_Schemes::get_posted_subscription_scheme($product_id); + } else { + // 兼容性回退:尝试从请求参数中获取。 + // APFS 默认使用 name=wcsatt_subscription_scheme 的字段提交所选方案;此处作为保护性回退。 + $posted_scheme_key = isset($_REQUEST['wcsatt_subscription_scheme']) ? wc_clean(wp_unslash($_REQUEST['wcsatt_subscription_scheme'])) : null; + // 若无法识别,则保持为 null/false,表示一次性购买。 + } + // 允许显式地传递 false,表示一次性购买。APFS 会在后续根据该键处理。 + $wcsatt_data = array('wcsatt_data' => array('active_subscription_scheme' => $posted_scheme_key)); + } + // 添加主混装产品(作为价格为0的容器) - WC()->cart->add_to_cart($product_id, 1, 0, array(), array('yoone_bundle_container_id' => $bundle_container_id)); + WC()->cart->add_to_cart($product_id, 1, 0, array(), array_merge($wcsatt_data, array( + 'yoone_bundle_container_id' => $bundle_container_id, + 'yoone_bundle_edit_in_cart' => $edit_in_cart ? 'yes' : 'no', + ))); // 添加子组件 foreach ($clean_components as $comp_id => $qty) { - WC()->cart->add_to_cart($comp_id, $qty, 0, array(), array( + WC()->cart->add_to_cart($comp_id, $qty, 0, array(), array_merge($wcsatt_data, array( 'yoone_bundle_parent_id' => $bundle_container_id, - 'yoone_bundle_parent_product_id' => $product_id - )); + 'yoone_bundle_parent_product_id' => $product_id, + 'yoone_bundle_edit_in_cart' => $edit_in_cart ? 'yes' : 'no', + ))); } // 设置成功消息并重定向 @@ -103,6 +148,44 @@ class Yoone_Product_Bundles_Frontend { return false; } + /** + * 当其它插件(如订阅插件)在单品页通过 $product->get_price_html() 生成价格片段时, + * 我们为 yoone_bundle 返回友好提示,避免显示 $0.00。 + */ + public function filter_yoone_bundle_price_html($html, $product) { + try { + if ($product && is_a($product, 'WC_Product') && $product->get_type() === Yoone_Product_Bundles::TYPE) { + if (is_product()) { + return '' . esc_html__('Price depends on selected items.', 'yoone-product-bundles') . ''; + } + } + } catch (\Throwable $e) { + // swallow errors + } + return $html; + } + + /** + * 防护:在“单品页”早期移除 APFS 对按钮文案的过滤器,以避免其函数签名与当前 Woo 版本不匹配导致的 500 错误。 + */ + public function remove_conflicting_apfs_add_to_cart_text_filter() { + try { + global $product; + if (!$product || !is_a($product, 'WC_Product')) { + return; + } + if ($product->get_type() !== Yoone_Product_Bundles::TYPE) { + return; // 仅在我们的混装产品上做防护,不影响其它产品。 + } + if (class_exists('WCS_ATT_Display_Product')) { + // APFS 在 includes/display/class-wcs-att-display-product.php 中以优先级 10 添加了该过滤器。 + remove_filter('woocommerce_product_single_add_to_cart_text', array('WCS_ATT_Display_Product', 'single_add_to_cart_text'), 10); + } + } catch (\Throwable $e) { + // 忽略错误,尽可能不中断页面。 + } + } + /** * 当一个购物车项目被移除时,检查它是否是一个混装容器。 * 如果是,则找到并移除其所有的子项目。 @@ -141,7 +224,11 @@ class Yoone_Product_Bundles_Frontend { public function hide_child_remove_link($link, $cart_item_key) { $cart_item = WC()->cart->get_cart_item($cart_item_key); if (isset($cart_item['yoone_bundle_parent_id'])) { - return ''; + // 当允许在购物车编辑时,不隐藏移除链接 + $editable = isset($cart_item['yoone_bundle_edit_in_cart']) && $cart_item['yoone_bundle_edit_in_cart'] === 'yes'; + if (! $editable) { + return ''; + } } return $link; } @@ -162,6 +249,108 @@ class Yoone_Product_Bundles_Frontend { return $item_data; } + /** + * 为购物车行添加样式类,便于将子项目缩小并缩进显示。 + */ + public function add_cart_item_group_classes($class, $cart_item, $cart_item_key) { + if (isset($cart_item['yoone_bundle_container_id'])) { + $class .= ' yoone-bundle-container'; + } elseif (isset($cart_item['yoone_bundle_parent_id'])) { + $class .= ' yoone-bundle-child'; + } + return $class; + } + + /** + * 计算混装容器的聚合价格与折扣后价格。 + */ + private function compute_container_totals($cart, $container_cart_item) { + $result = array('raw_subtotal' => 0.0, 'discounted_subtotal' => 0.0, 'discount' => 0.0); + if (!isset($container_cart_item['yoone_bundle_container_id'])) { + return $result; + } + $bundle_id = $container_cart_item['yoone_bundle_container_id']; + + // 找出对应的父产品(用于读取折扣配置) + $parent_product_id = 0; + if (isset($container_cart_item['product_id'])) { + $parent_product_id = absint($container_cart_item['product_id']); + } + $parent_product = $parent_product_id ? wc_get_product($parent_product_id) : null; + $config = $parent_product ? Yoone_Product_Bundles::get_bundle_config($parent_product) : array('discount_type' => 'none', 'discount_amount' => 0); + $type = isset($config['discount_type']) ? $config['discount_type'] : 'none'; + $amount = isset($config['discount_amount']) ? floatval($config['discount_amount']) : 0.0; + + foreach ($cart->get_cart() as $key => $item) { + if (isset($item['yoone_bundle_parent_id']) && $item['yoone_bundle_parent_id'] === $bundle_id) { + $qty = isset($item['quantity']) ? absint($item['quantity']) : 1; + $price = 0.0; + if (isset($item['data']) && is_object($item['data']) && method_exists($item['data'], 'get_price')) { + $price = floatval($item['data']->get_price()); + } + $result['raw_subtotal'] += ($price * $qty); + } + } + + $discount = 0.0; + if ($type !== 'none' && $amount > 0 && $result['raw_subtotal'] > 0) { + if ($type === 'percent') { + $discount = $result['raw_subtotal'] * ($amount / 100.0); + } else if ($type === 'fixed') { + $discount = min($amount, $result['raw_subtotal']); + } + } + $result['discount'] = $discount; + $result['discounted_subtotal'] = max(0, $result['raw_subtotal'] - $discount); + return $result; + } + + /** + * 在购物车“单价”栏为容器显示聚合价格与折扣节省。 + */ + public function render_container_cart_price($price_html, $cart_item, $cart_item_key) { + if (!isset($cart_item['yoone_bundle_container_id'])) { + return $price_html; + } + $cart = WC()->cart; + $totals = $this->compute_container_totals($cart, $cart_item); + $currency = get_woocommerce_currency_symbol(); + $format = function($n) use ($currency) { return wc_price($n); }; + + if ($totals['raw_subtotal'] <= 0) { + return $price_html; + } + + $html = ''; + if ($totals['discount'] > 0) { + // 显示原价与折后价,以及节省标签 + $html .= '' . $format($totals['raw_subtotal']) . ' '; + $html .= '' . $format($totals['discounted_subtotal']) . ''; + $html .= '
' . sprintf(esc_html__('SAVE %s', 'yoone-product-bundles'), $format($totals['discount'])) . '
'; + } else { + // 无折扣时仅显示聚合总价 + $html .= '' . $format($totals['raw_subtotal']) . ''; + } + return $html; + } + + /** + * 在购物车“小计”栏为容器显示“应付”金额(折后总价)。 + */ + public function render_container_cart_subtotal($subtotal_html, $cart_item, $cart_item_key) { + if (!isset($cart_item['yoone_bundle_container_id'])) { + return $subtotal_html; + } + $cart = WC()->cart; + $totals = $this->compute_container_totals($cart, $cart_item); + if ($totals['raw_subtotal'] <= 0) { + return $subtotal_html; + } + $format = function($n) { return wc_price($n); }; + $html = '' . $format($totals['discounted_subtotal']) . ''; + return $html; + } + /** * 仅为混装产品移除默认的“添加到购物车”按钮。 */ @@ -172,6 +361,19 @@ class Yoone_Product_Bundles_Frontend { } } + /** + * 在单品页隐藏默认价格输出,并显示“价格由所选子商品决定”的说明。 + */ + public function replace_single_price_for_bundle() { + global $product; + if ($product && $product->get_type() === Yoone_Product_Bundles::TYPE) { + // 移除默认价格输出 + remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_price', 10); + // 输出说明文本(可根据需要美化) + echo '

' . esc_html__('Price depends on selected items in the bundle.', 'yoone-product-bundles') . '

'; + } + } + /** * 为混装产品渲染自定义的“添加到购物车”表单。 */ @@ -195,4 +397,66 @@ class Yoone_Product_Bundles_Frontend { } return $classes; } + + /** + * 遍历购物车,按容器分组子项目,计算各容器对应的折扣,并以负费用的形式添加到购物车。 + * 折扣类型支持:percent(百分比)或 fixed(固定金额)。 + */ + public function apply_bundle_discounts($cart) { + if (is_admin() && !defined('DOING_AJAX')) { + return; + } + if (empty($cart)) return; + + // 收集所有容器及其子项的金额 + $bundles = array(); // bundle_container_id => [ 'parent_product_id' => int, 'subtotal' => float ] + + foreach ($cart->get_cart() as $cart_item_key => $cart_item) { + if (isset($cart_item['yoone_bundle_parent_id'])) { + $bundle_id = $cart_item['yoone_bundle_parent_id']; + $parent_pid = isset($cart_item['yoone_bundle_parent_product_id']) ? absint($cart_item['yoone_bundle_parent_product_id']) : 0; + if (!isset($bundles[$bundle_id])) { + $bundles[$bundle_id] = array('parent_product_id' => $parent_pid, 'subtotal' => 0.0); + } + // 使用当前商品价格 * 数量 计算小计(不考虑税) + $price = 0.0; + if (isset($cart_item['data']) && is_object($cart_item['data']) && method_exists($cart_item['data'], 'get_price')) { + $price = floatval($cart_item['data']->get_price()); + } + $qty = isset($cart_item['quantity']) ? absint($cart_item['quantity']) : 1; + $bundles[$bundle_id]['subtotal'] += ($price * $qty); + } + } + + // 为每个 bundle 容器应用折扣 + foreach ($bundles as $bundle_id => $info) { + $parent_product = $info['parent_product_id'] ? wc_get_product($info['parent_product_id']) : null; + if (!$parent_product || $parent_product->get_type() !== Yoone_Product_Bundles::TYPE) { + continue; + } + $config = Yoone_Product_Bundles::get_bundle_config($parent_product); + $type = isset($config['discount_type']) ? $config['discount_type'] : 'none'; + $amount = isset($config['discount_amount']) ? floatval($config['discount_amount']) : 0.0; + if ($type === 'none' || $amount <= 0) { + continue; + } + + $subtotal = floatval($info['subtotal']); + if ($subtotal <= 0) continue; + + $discount = 0.0; + if ($type === 'percent') { + $discount = $subtotal * ($amount / 100.0); + } else if ($type === 'fixed') { + // 固定金额:每个 bundle 容器减固定金额 + $discount = min($amount, $subtotal); + } + + if ($discount > 0) { + $title = sprintf(__('Bundle Discount: %s', 'yoone-product-bundles'), $parent_product->get_name()); + // 添加负费用以抵扣;不含税 + $cart->add_fee($title, -1 * $discount, false); + } + } + } } \ No newline at end of file diff --git a/includes/integration/class-yoone-pb-apfs-integration.php b/includes/integration/class-yoone-pb-apfs-integration.php new file mode 100644 index 0000000..afe2813 --- /dev/null +++ b/includes/integration/class-yoone-pb-apfs-integration.php @@ -0,0 +1,138 @@ +get_type() === Yoone_Product_Bundles::TYPE) { + // Replace price_html with hint, keep descriptions intact. + $data['price_html'] = '' . esc_html__('Price depends on selected items.', 'yoone-product-bundles') . ''; + } + } catch (\Throwable $e) { + // Fail silently to avoid breaking APFS rendering. + } + return $data; + } + + /** + * Declare APFS features supported by yoone_bundle products. + * Mirrors capabilities of official 'bundle' type so APFS renders single-product options. + * + * @param bool $supported + * @param WC_Product $product + * @param string $feature + * @return bool + */ + public static function product_supports_feature($supported, $product, $feature) { + if (! $product || ! is_a($product, 'WC_Product')) { + return $supported; + } + if ($product->get_type() !== Yoone_Product_Bundles::TYPE) { + return $supported; + } + switch ($feature) { + case 'subscription_schemes': + case 'subscription_scheme_switching': + case 'subscription_content_switching': + case 'subscription_scheme_options_product_single': + case 'subscription_scheme_options_product_cart': + case 'subscription_management_add_to_subscription_product_single': + return true; + } + return $supported; + } + + /** + * Ensure SATT admin tab is visible for yoone_bundle products. + * + * @param array $tabs + * @return array + */ + public static function add_show_if_class_for_tab($tabs) { + if (isset($tabs['satt'])) { + if (! isset($tabs['satt']['class']) || ! is_array($tabs['satt']['class'])) { + $tabs['satt']['class'] = array(); + } + $tabs['satt']['class'][] = 'show_if_' . Yoone_Product_Bundles::TYPE; // show_if_yoone_bundle + } + return $tabs; + } + + /** + * Ensure children of a yoone_bundle container inherit the same subscription scheme when it changes. + * + * @param array $cart_item + * @param string $cart_item_key + */ + public static function propagate_scheme_to_children($cart_item, $cart_item_key) { + if (empty($cart_item['yoone_bundle_container_id'])) { + // Not a container; skip. + return; + } + if (! isset($cart_item['wcsatt_data'])) { + return; + } + $active_scheme = isset($cart_item['wcsatt_data']['active_subscription_scheme']) ? $cart_item['wcsatt_data']['active_subscription_scheme'] : null; + if (null === $active_scheme) { + // No explicit choice made; let APFS defaults handle children. + return; + } + $container_id = $cart_item['yoone_bundle_container_id']; + foreach (WC()->cart->get_cart() as $child_key => $child_item) { + if (isset($child_item['yoone_bundle_parent_id']) && $child_item['yoone_bundle_parent_id'] === $container_id) { + if (! isset(WC()->cart->cart_contents[$child_key]['wcsatt_data'])) { + WC()->cart->cart_contents[$child_key]['wcsatt_data'] = array(); + } + WC()->cart->cart_contents[$child_key]['wcsatt_data']['active_subscription_scheme'] = $active_scheme; + } + } + } +} + +// Initialize after plugins are loaded to ensure APFS hooks are available. +add_action('plugins_loaded', array('Yoone_PB_APFS_Integration', 'init'), 20); \ No newline at end of file diff --git a/templates/global/yoone-bundle-form.php b/templates/global/yoone-bundle-form.php index b74b00f..e43be5f 100644 --- a/templates/global/yoone-bundle-form.php +++ b/templates/global/yoone-bundle-form.php @@ -18,6 +18,16 @@ $config = Yoone_Product_Bundles::get_bundle_config($product); $allowed = $config['allowed_products']; $select_mode = isset($config['select_mode']) ? $config['select_mode'] : 'include'; $min_qty = max(0, absint($config['min_qty'])); +// 是否允许在购物车中编辑混装 +$edit_in_cart_enabled = isset($config['edit_in_cart']) && $config['edit_in_cart'] === 'yes'; +// 折扣设置 +$discount_type = isset($config['discount_type']) ? $config['discount_type'] : 'none'; +$discount_amount = isset($config['discount_amount']) ? floatval($config['discount_amount']) : 0.0; +// 价格显示格式(用于前端计算与显示) +$currency_symbol = get_woocommerce_currency_symbol(); +$price_decimals = wc_get_price_decimals(); +$decimal_separator = wc_get_price_decimal_separator(); +$thousand_separator = wc_get_price_thousand_separator(); // 读取按 taxonomy 分组的配置(支持 product_cat / product_tag) $group_taxonomy = isset($config['group_taxonomy']) ? $config['group_taxonomy'] : 'product_cat'; $term_ids = isset($config['group_terms']) ? (array) $config['group_terms'] : array(); @@ -77,21 +87,51 @@ if (!empty($term_ids)) { ?>
-
+ +

0

+ +
+ +
+ +
+

+ + 0 +

+ 0) : ?> +

+ + 0 +

+ +
- + + +
- -

@@ -129,7 +169,7 @@ if (!empty($term_ids)) { get_price_html()); ?>
- +
@@ -138,11 +178,6 @@ if (!empty($term_ids)) {
- - - + \ No newline at end of file diff --git a/yoone-product-bundles.php b/yoone-product-bundles.php index 023c4a0..c5df572 100644 --- a/yoone-product-bundles.php +++ b/yoone-product-bundles.php @@ -30,6 +30,8 @@ require_once YOONE_PB_PATH . 'includes/class-yoone-product-bundles.php'; require_once YOONE_PB_PATH . 'includes/class-yoone-product-type-bundle.php'; require_once YOONE_PB_PATH . 'includes/admin/class-yoone-product-bundles-admin.php'; require_once YOONE_PB_PATH . 'includes/frontend/class-yoone-product-bundles-frontend.php'; +// APFS integration (WooCommerce All Products for Subscriptions) +require_once YOONE_PB_PATH . 'includes/integration/class-yoone-pb-apfs-integration.php'; require_once YOONE_PB_PATH . 'includes/blocks/register.php'; require_once YOONE_PB_PATH . 'includes/shortcodes/register.php'; // 注意:Elementor 小组件文件依赖 Elementor 的类,需在 Elementor 加载后再引入,避免致命错误