feat: 添加产品捆绑折扣和购物车编辑功能
新增产品捆绑折扣功能,支持百分比和固定金额折扣 添加购物车中编辑捆绑产品子项的功能 优化前端价格显示和购物车样式 集成 WooCommerce All Products for Subscriptions 插件 重构后台管理界面,增加折扣和编辑选项
This commit is contained in:
parent
dac5f1ab84
commit
46bc50846a
|
|
@ -71,4 +71,42 @@
|
||||||
.yoone-bundle-item-card .item-quantity input {
|
.yoone-bundle-item-card .item-quantity input {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
text-align: center;
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/* Yoone Product Bundles - Admin dynamic grouping terms
|
/* Yoone Product Bundles - Admin dynamic grouping terms
|
||||||
* When switching grouping taxonomy (category/tag), refresh the terms select list accordingly.
|
* 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($){
|
||||||
$(function(){
|
$(function(){
|
||||||
var $tax = $('#yoone_bundle_group_taxonomy');
|
var $tax = $('#yoone_bundle_group_taxonomy');
|
||||||
|
|
@ -66,10 +67,6 @@
|
||||||
$productSelect.prop('disabled', isAll);
|
$productSelect.prop('disabled', isAll);
|
||||||
$addAllBtn.prop('disabled', isAll);
|
$addAllBtn.prop('disabled', isAll);
|
||||||
}
|
}
|
||||||
if ($selectMode.length) {
|
|
||||||
updateProductsListState();
|
|
||||||
$selectMode.on('change', updateProductsListState);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add all simple products to the products select
|
// Add all simple products to the products select
|
||||||
if ($addAllBtn.length && $productSelect.length) {
|
if ($addAllBtn.length && $productSelect.length) {
|
||||||
|
|
@ -117,8 +114,7 @@
|
||||||
$productSelect.val([]).trigger('change');
|
$productSelect.val([]).trigger('change');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
})(jQuery);
|
|
||||||
function renderEffectiveList(items, count) {
|
function renderEffectiveList(items, count) {
|
||||||
var $list = $('#yoone_effective_products_list');
|
var $list = $('#yoone_effective_products_list');
|
||||||
var $count = $('.yoone-effective-count');
|
var $count = $('.yoone-effective-count');
|
||||||
|
|
@ -174,4 +170,6 @@
|
||||||
$refresh.on('click', function(){
|
$refresh.on('click', function(){
|
||||||
recomputeEffective();
|
recomputeEffective();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
})(jQuery);
|
||||||
|
|
@ -4,6 +4,15 @@
|
||||||
$(function() {
|
$(function() {
|
||||||
var minQty = 0;
|
var minQty = 0;
|
||||||
var wrapper = $('.yoone-bundle-form');
|
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 中获取最小数量
|
// 从 DOM 中获取最小数量
|
||||||
var minText = wrapper.find('.yoone-bundle-min').text();
|
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() {
|
function updateState() {
|
||||||
var total = 0;
|
var total = 0;
|
||||||
wrapper.find('.yoone-bundle-qty').each(function() {
|
wrapper.find('.yoone-bundle-qty').each(function() {
|
||||||
var v = parseInt($(this).val(), 10);
|
var v = parseInt($(this).val(), 10);
|
||||||
|
var unit = parseFloat($(this).data('unit-price') || '0');
|
||||||
if (!isNaN(v) && v > 0) {
|
if (!isNaN(v) && v > 0) {
|
||||||
total += v;
|
total += v;
|
||||||
}
|
}
|
||||||
|
|
@ -33,6 +56,26 @@
|
||||||
} else {
|
} else {
|
||||||
btn.prop('disabled', true);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绑定事件
|
// 绑定事件
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class Yoone_Product_Bundles_Admin {
|
||||||
|
|
||||||
public function add_product_data_tab($tabs) {
|
public function add_product_data_tab($tabs) {
|
||||||
$tabs['yoone_bundle'] = array(
|
$tabs['yoone_bundle'] = array(
|
||||||
'label' => __('Mix and Match', 'yoone-product-bundles'),
|
'label' => __('Product Bundle (Yoone Bundle)', 'yoone-product-bundles'),
|
||||||
'target' => 'yoone_bundle_data',
|
'target' => 'yoone_bundle_data',
|
||||||
'class' => array('show_if_yoone_bundle'), // Only show for 'yoone_bundle' type
|
'class' => array('show_if_yoone_bundle'), // Only show for 'yoone_bundle' type
|
||||||
'priority' => 70,
|
'priority' => 70,
|
||||||
|
|
@ -64,9 +64,12 @@ class Yoone_Product_Bundles_Admin {
|
||||||
// 供渲染“有效产品总数”的计算结果
|
// 供渲染“有效产品总数”的计算结果
|
||||||
$effective_allowed = isset($config['allowed_products']) ? (array) $config['allowed_products'] : array();
|
$effective_allowed = isset($config['allowed_products']) ? (array) $config['allowed_products'] : array();
|
||||||
$min_qty = $config['min_qty'];
|
$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';
|
$select_mode = isset($config['select_mode']) ? $config['select_mode'] : 'include';
|
||||||
$group_taxonomy = isset($config['group_taxonomy']) ? $config['group_taxonomy'] : 'product_cat';
|
$group_taxonomy = isset($config['group_taxonomy']) ? $config['group_taxonomy'] : 'product_cat';
|
||||||
$group_terms = isset($config['group_terms']) ? (array) $config['group_terms'] : array();
|
$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 '<div id="yoone_bundle_data" class="panel woocommerce_options_panel show_if_yoone_bundle">';
|
echo '<div id="yoone_bundle_data" class="panel woocommerce_options_panel show_if_yoone_bundle">';
|
||||||
wp_nonce_field('yoone-bundle-admin-nonce', 'yoone_bundle_admin_nonce_field');
|
wp_nonce_field('yoone-bundle-admin-nonce', 'yoone_bundle_admin_nonce_field');
|
||||||
|
|
@ -89,38 +92,36 @@ class Yoone_Product_Bundles_Admin {
|
||||||
'value' => $select_mode,
|
'value' => $select_mode,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Allowed/Excluded products: use Woo's product search (select2), multiple
|
// Allowed/Excluded products selector: always render the field and select element,
|
||||||
if ($select_mode !== 'all') {
|
// but hide it initially when in 'all' mode so that toggling from ALL -> include/exclude works without a full reload.
|
||||||
$list_label = ($select_mode === 'exclude')
|
$is_all = ($select_mode === 'all');
|
||||||
? esc_html__('Excluded Products', 'yoone-product-bundles')
|
$list_label = ($select_mode === 'exclude')
|
||||||
: esc_html__('Included Products', 'yoone-product-bundles');
|
? esc_html__('Excluded Products', 'yoone-product-bundles')
|
||||||
echo '<p class="form-field" id="yoone_bundle_products_list_field"><label>' . $list_label . '</label>';
|
: esc_html__('Included Products', 'yoone-product-bundles');
|
||||||
echo '<button type="button" class="button yoone-add-all-simple-products">' . esc_html__('Add All Simple Products', 'yoone-product-bundles') . '</button>';
|
echo '<p class="form-field" id="yoone_bundle_products_list_field"' . ($is_all ? ' style="display:none"' : '') . '>';
|
||||||
echo ' ';
|
echo '<label>' . $list_label . '</label>';
|
||||||
echo '<button type="button" class="button yoone-clear-products-list">' . esc_html__('Clear Products List', 'yoone-product-bundles') . '</button>';
|
echo '<button type="button" class="button yoone-add-all-simple-products">' . esc_html__('Add All Simple Products', 'yoone-product-bundles') . '</button>';
|
||||||
// Search only for products, not variations, to avoid errors
|
echo ' ';
|
||||||
echo '<select class="wc-product-search" multiple style="width: 100%;" name="yoone_bundle_allowed_products[]" data-placeholder="' . esc_attr__('Search for simple products…', 'yoone-product-bundles') . '" data-action="woocommerce_json_search_products">';
|
echo '<button type="button" class="button yoone-clear-products-list">' . esc_html__('Clear Products List', 'yoone-product-bundles') . '</button>';
|
||||||
// 显示“原始配置列表”作为可编辑项(exclude 模式记为排除列表,include 模式记为包含列表)
|
// WooCommerce product search select. We keep the select always in DOM for JS to toggle.
|
||||||
if (! empty($allowed_raw)) {
|
echo '<select class="wc-product-search" multiple style="width: 100%;" name="yoone_bundle_allowed_products[]" data-placeholder="' . esc_attr__('Search for simple products…', 'yoone-product-bundles') . '" data-action="woocommerce_json_search_products">';
|
||||||
foreach ($allowed_raw as $pid) {
|
// 显示“原始配置列表”作为可编辑项(exclude 模式记为排除列表,include 模式记为包含列表)
|
||||||
$p = wc_get_product($pid);
|
if (! empty($allowed_raw)) {
|
||||||
if ($p) {
|
foreach ($allowed_raw as $pid) {
|
||||||
printf('<option value="%d" selected>%s</option>', $pid, esc_html($p->get_formatted_name()));
|
$p = wc_get_product($pid);
|
||||||
}
|
if ($p) {
|
||||||
|
printf('<option value="%d" selected>%s</option>', $pid, esc_html($p->get_formatted_name()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
echo '</select>';
|
|
||||||
// Dynamic description based on selection mode
|
|
||||||
if ($select_mode === 'include') {
|
|
||||||
echo '<span class="description yoone-bundle-list-desc">' . esc_html__('Simple products only. In Include mode, ONLY the listed products will be available.', 'yoone-product-bundles') . '</span>';
|
|
||||||
} else { // exclude
|
|
||||||
echo '<span class="description yoone-bundle-list-desc">' . esc_html__('Simple products only. In Exclude mode, ALL published simple products are available EXCEPT the listed ones.', 'yoone-product-bundles') . '</span>';
|
|
||||||
}
|
|
||||||
echo '</p>';
|
|
||||||
} else {
|
|
||||||
// All 模式:不显示选择框,仅提示说明
|
|
||||||
echo '<p class="form-field" id="yoone_bundle_products_list_field" style="display:none"></p>';
|
|
||||||
}
|
}
|
||||||
|
echo '</select>';
|
||||||
|
// Dynamic description based on selection mode
|
||||||
|
if ($select_mode === 'include') {
|
||||||
|
echo '<span class="description yoone-bundle-list-desc">' . esc_html__('Simple products only. In Include mode, ONLY the listed products will be available.', 'yoone-product-bundles') . '</span>';
|
||||||
|
} else { // exclude or all (label will be updated via JS when switching)
|
||||||
|
echo '<span class="description yoone-bundle-list-desc">' . esc_html__('Simple products only. In Exclude mode, ALL published simple products are available EXCEPT the listed ones.', 'yoone-product-bundles') . '</span>';
|
||||||
|
}
|
||||||
|
echo '</p>';
|
||||||
|
|
||||||
// 只读展示:有效产品列表与总数(基于当前配置推导)
|
// 只读展示:有效产品列表与总数(基于当前配置推导)
|
||||||
echo '<div id="yoone_effective_products_block" class="yoone-effective-products" style="margin-top:10px;">';
|
echo '<div id="yoone_effective_products_block" class="yoone-effective-products" style="margin-top:10px;">';
|
||||||
|
|
@ -155,6 +156,40 @@ class Yoone_Product_Bundles_Admin {
|
||||||
'custom_attributes' => array('min' => '0'),
|
'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)
|
// Grouping taxonomy (product_cat or product_tag)
|
||||||
woocommerce_wp_select(array(
|
woocommerce_wp_select(array(
|
||||||
'id' => 'yoone_bundle_group_taxonomy',
|
'id' => 'yoone_bundle_group_taxonomy',
|
||||||
|
|
@ -187,7 +222,7 @@ class Yoone_Product_Bundles_Admin {
|
||||||
public function save_product_meta($post_id) {
|
public function save_product_meta($post_id) {
|
||||||
// 无论当前 product 对象类型为何,只要提交了我们的字段,就进行保存。
|
// 无论当前 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;
|
if (! $has_fields) return;
|
||||||
|
|
||||||
// 保存 allowed products
|
// 保存 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;
|
$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));
|
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)
|
// 保存选择模式(include | exclude | all)
|
||||||
$select_mode = isset($_POST['yoone_bundle_select_mode']) ? sanitize_text_field($_POST['yoone_bundle_select_mode']) : 'include';
|
$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';
|
$select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include';
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ class Yoone_Product_Bundles {
|
||||||
// Post meta keys for configuration
|
// Post meta keys for configuration
|
||||||
const META_ALLOWED_PRODUCTS = '_yoone_bundle_allowed_products'; // array<int>
|
const META_ALLOWED_PRODUCTS = '_yoone_bundle_allowed_products'; // array<int>
|
||||||
const META_MIN_QTY = '_yoone_bundle_min_quantity'; // int
|
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 产品)
|
// 选择模式:include(仅包含列表)、exclude(排除列表,其余全部允许)、all(全部 simple 产品)
|
||||||
const META_SELECT_MODE = '_yoone_bundle_select_mode'; // 'include' | 'exclude' | 'all'
|
const META_SELECT_MODE = '_yoone_bundle_select_mode'; // 'include' | 'exclude' | 'all'
|
||||||
// 旧版:仅支持分类(product_cat)。
|
// 旧版:仅支持分类(product_cat)。
|
||||||
|
|
@ -38,7 +43,7 @@ class Yoone_Product_Bundles {
|
||||||
* Add "Mix and Match" to the "Product Type" dropdown in the admin.
|
* Add "Mix and Match" to the "Product Type" dropdown in the admin.
|
||||||
*/
|
*/
|
||||||
public function register_product_type_in_selector($types) {
|
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;
|
return $types;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,6 +68,12 @@ class Yoone_Product_Bundles {
|
||||||
$pid = $product->get_id();
|
$pid = $product->get_id();
|
||||||
$allowed = get_post_meta($pid, self::META_ALLOWED_PRODUCTS, true);
|
$allowed = get_post_meta($pid, self::META_ALLOWED_PRODUCTS, true);
|
||||||
$min = absint(get_post_meta($pid, self::META_MIN_QTY, 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 = get_post_meta($pid, self::META_SELECT_MODE, true);
|
||||||
$select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include';
|
$select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include';
|
||||||
// 读取新的分组设置
|
// 读取新的分组设置
|
||||||
|
|
@ -110,6 +121,9 @@ class Yoone_Product_Bundles {
|
||||||
return array(
|
return array(
|
||||||
'allowed_products' => $allowed,
|
'allowed_products' => $allowed,
|
||||||
'min_qty' => max(0, $min),
|
'min_qty' => max(0, $min),
|
||||||
|
'edit_in_cart' => $edit_in_cart,
|
||||||
|
'discount_type' => $discount_type,
|
||||||
|
'discount_amount' => $discount_amount,
|
||||||
'select_mode' => $select_mode,
|
'select_mode' => $select_mode,
|
||||||
'group_taxonomy' => $group_tax,
|
'group_taxonomy' => $group_tax,
|
||||||
'group_terms' => $group_terms,
|
'group_terms' => $group_terms,
|
||||||
|
|
|
||||||
|
|
@ -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_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,以便于样式化
|
// 为混装产品页面添加 body class,以便于样式化
|
||||||
add_filter('body_class', array($this, 'filter_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, '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);
|
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. 验证
|
// 1. 验证
|
||||||
$config = Yoone_Product_Bundles::get_bundle_config($product);
|
$config = Yoone_Product_Bundles::get_bundle_config($product);
|
||||||
$min_qty = max(1, absint($config['min_qty']));
|
$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();
|
$components = !empty($_POST['yoone_bundle_components']) ? (array) $_POST['yoone_bundle_components'] : array();
|
||||||
|
|
||||||
$total_qty = 0;
|
$total_qty = 0;
|
||||||
|
|
@ -79,15 +102,37 @@ class Yoone_Product_Bundles_Frontend {
|
||||||
try {
|
try {
|
||||||
$bundle_container_id = uniqid('bundle_');
|
$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的容器)
|
// 添加主混装产品(作为价格为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) {
|
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_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;
|
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 '<span class="yoone-bundle-option-price-hint">' . esc_html__('Price depends on selected items.', 'yoone-product-bundles') . '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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) {
|
public function hide_child_remove_link($link, $cart_item_key) {
|
||||||
$cart_item = WC()->cart->get_cart_item($cart_item_key);
|
$cart_item = WC()->cart->get_cart_item($cart_item_key);
|
||||||
if (isset($cart_item['yoone_bundle_parent_id'])) {
|
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;
|
return $link;
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +249,108 @@ class Yoone_Product_Bundles_Frontend {
|
||||||
return $item_data;
|
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 .= '<span class="yoone-bundle-original" style="text-decoration:line-through;opacity:.7;">' . $format($totals['raw_subtotal']) . '</span> ';
|
||||||
|
$html .= '<span class="yoone-bundle-discounted" style="font-weight:600;">' . $format($totals['discounted_subtotal']) . '</span>';
|
||||||
|
$html .= '<div class="yoone-bundle-savings" style="margin-top:4px;">' . sprintf(esc_html__('SAVE %s', 'yoone-product-bundles'), $format($totals['discount'])) . '</div>';
|
||||||
|
} else {
|
||||||
|
// 无折扣时仅显示聚合总价
|
||||||
|
$html .= '<span class="yoone-bundle-aggregated" style="font-weight:600;">' . $format($totals['raw_subtotal']) . '</span>';
|
||||||
|
}
|
||||||
|
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 = '<span class="yoone-bundle-due-today" style="font-weight:600;">' . $format($totals['discounted_subtotal']) . '</span>';
|
||||||
|
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 '<p class="price yoone-bundle-price-hint">' . esc_html__('Price depends on selected items in the bundle.', 'yoone-product-bundles') . '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为混装产品渲染自定义的“添加到购物车”表单。
|
* 为混装产品渲染自定义的“添加到购物车”表单。
|
||||||
*/
|
*/
|
||||||
|
|
@ -195,4 +397,66 @@ class Yoone_Product_Bundles_Frontend {
|
||||||
}
|
}
|
||||||
return $classes;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Integration: WooCommerce All Products for Subscriptions (APFS)
|
||||||
|
*
|
||||||
|
* Enables subscription scheme UI and cart behavior on custom product type 'yoone_bundle'.
|
||||||
|
* - Adds 'yoone_bundle' to supported product types.
|
||||||
|
* - Marks key features as supported via 'wcsatt_product_supports_feature'.
|
||||||
|
* - Ensures the SATT admin tab shows on 'yoone_bundle' products.
|
||||||
|
* - Propagates the chosen plan from the bundle container to its children in cart.
|
||||||
|
*/
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
class Yoone_PB_APFS_Integration {
|
||||||
|
|
||||||
|
public static function init() {
|
||||||
|
// Extend supported product types.
|
||||||
|
add_filter('wcsatt_supported_product_types', array(__CLASS__, 'extend_supported_types'));
|
||||||
|
// Declare features support for our product type.
|
||||||
|
add_filter('wcsatt_product_supports_feature', array(__CLASS__, 'product_supports_feature'), 10, 3);
|
||||||
|
// Show SATT tab on yoone_bundle admin screens.
|
||||||
|
add_filter('woocommerce_product_data_tabs', array(__CLASS__, 'add_show_if_class_for_tab'), 20, 1);
|
||||||
|
// After APFS applies a scheme on a cart item, propagate the scheme from container to its children.
|
||||||
|
add_action('wcsatt_applied_cart_item_subscription_scheme', array(__CLASS__, 'propagate_scheme_to_children'), 10, 2);
|
||||||
|
// Tweak single-product option data for yoone_bundle to avoid $0.00 confusion.
|
||||||
|
add_filter('wcsatt_single_product_one_time_option_data', array(__CLASS__, 'filter_single_option_price_html'), 10, 3);
|
||||||
|
add_filter('wcsatt_single_product_subscription_option_data', array(__CLASS__, 'filter_single_option_price_html'), 10, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add 'yoone_bundle' to APFS supported product types.
|
||||||
|
*
|
||||||
|
* @param array $types
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function extend_supported_types($types) {
|
||||||
|
$types[] = Yoone_Product_Bundles::TYPE; // 'yoone_bundle'
|
||||||
|
return array_values(array_unique($types));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust the price HTML shown in APFS single-product options for yoone_bundle.
|
||||||
|
* For our zero-priced container, show a friendly message instead of $0.00.
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @param WC_Product $product
|
||||||
|
* @param string $key_or_scheme_key
|
||||||
|
* @param mixed $scheme Optional.
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function filter_single_option_price_html($data, $product = null, $key_or_scheme_key = null, $scheme = null) {
|
||||||
|
try {
|
||||||
|
if ($product && is_a($product, 'WC_Product') && $product->get_type() === Yoone_Product_Bundles::TYPE) {
|
||||||
|
// Replace price_html with hint, keep descriptions intact.
|
||||||
|
$data['price_html'] = '<span class="yoone-bundle-option-price-hint">' . esc_html__('Price depends on selected items.', 'yoone-product-bundles') . '</span>';
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
|
@ -18,6 +18,16 @@ $config = Yoone_Product_Bundles::get_bundle_config($product);
|
||||||
$allowed = $config['allowed_products'];
|
$allowed = $config['allowed_products'];
|
||||||
$select_mode = isset($config['select_mode']) ? $config['select_mode'] : 'include';
|
$select_mode = isset($config['select_mode']) ? $config['select_mode'] : 'include';
|
||||||
$min_qty = max(0, absint($config['min_qty']));
|
$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)
|
// 读取按 taxonomy 分组的配置(支持 product_cat / product_tag)
|
||||||
$group_taxonomy = isset($config['group_taxonomy']) ? $config['group_taxonomy'] : 'product_cat';
|
$group_taxonomy = isset($config['group_taxonomy']) ? $config['group_taxonomy'] : 'product_cat';
|
||||||
$term_ids = isset($config['group_terms']) ? (array) $config['group_terms'] : array();
|
$term_ids = isset($config['group_terms']) ? (array) $config['group_terms'] : array();
|
||||||
|
|
@ -77,21 +87,51 @@ if (!empty($term_ids)) {
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="yoone-bundle-form-container">
|
<div class="yoone-bundle-form-container">
|
||||||
<form class="cart yoone-bundle-form" action="<?php echo esc_url(apply_filters('woocommerce_add_to_cart_form_action', $product->get_permalink())); ?>" method="post" enctype="multipart/form-data">
|
<?php do_action('woocommerce_before_add_to_cart_form'); ?>
|
||||||
|
<form class="cart yoone-bundle-form" action="<?php echo esc_url(apply_filters('woocommerce_add_to_cart_form_action', $product->get_permalink())); ?>" method="post" enctype="multipart/form-data"
|
||||||
|
data-currency-symbol="<?php echo esc_attr($currency_symbol); ?>"
|
||||||
|
data-price-decimals="<?php echo esc_attr($price_decimals); ?>"
|
||||||
|
data-decimal-separator="<?php echo esc_attr($decimal_separator); ?>"
|
||||||
|
data-thousand-separator="<?php echo esc_attr($thousand_separator); ?>"
|
||||||
|
data-discount-type="<?php echo esc_attr($discount_type); ?>"
|
||||||
|
data-discount-amount="<?php echo esc_attr($discount_amount); ?>"
|
||||||
|
>
|
||||||
<div class="yoone-bundle-meta">
|
<div class="yoone-bundle-meta">
|
||||||
<p class="yoone-bundle-min"><?php printf(esc_html__('Select at least %d items to build your bundle.', 'yoone-product-bundles'), $min_qty); ?></p>
|
<p class="yoone-bundle-min"><?php printf(esc_html__('Select at least %d items to build your bundle.', 'yoone-product-bundles'), $min_qty); ?></p>
|
||||||
<p class="yoone-bundle-selected"><?php esc_html_e('Currently selected:', 'yoone-product-bundles'); ?> <span class="yoone-bundle-selected-count">0</span></p>
|
<p class="yoone-bundle-selected"><?php esc_html_e('Currently selected:', 'yoone-product-bundles'); ?> <span class="yoone-bundle-selected-count">0</span></p>
|
||||||
|
<?php if ($edit_in_cart_enabled) : ?>
|
||||||
|
<div class="yoone-bundle-edit-in-cart">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="yoone_bundle_edit_in_cart" value="1" checked />
|
||||||
|
<?php esc_html_e('Allow editing bundle items in Cart (quantities and removal).', 'yoone-product-bundles'); ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="yoone-bundle-totals">
|
||||||
|
<p class="yoone-bundle-total-raw">
|
||||||
|
<?php esc_html_e('Selected items total:', 'yoone-product-bundles'); ?>
|
||||||
|
<span class="yoone-bundle-total-raw-value">0</span>
|
||||||
|
</p>
|
||||||
|
<?php if ($discount_type !== 'none' && $discount_amount > 0) : ?>
|
||||||
|
<p class="yoone-bundle-total-discounted">
|
||||||
|
<?php esc_html_e('After bundle discount:', 'yoone-product-bundles'); ?>
|
||||||
|
<span class="yoone-bundle-total-discounted-value">0</span>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
<div class="yoone-bundle-actions">
|
<div class="yoone-bundle-actions">
|
||||||
<input type="hidden" name="add-to-cart" value="<?php echo esc_attr($product->get_id()); ?>" />
|
<input type="hidden" name="add-to-cart" value="<?php echo esc_attr($product->get_id()); ?>" />
|
||||||
<button type="submit" class="single_add_to_cart_button button alt" disabled><?php esc_html_e('Add to Cart', 'yoone-product-bundles'); ?></button>
|
<?php
|
||||||
|
// 在按钮之前触发钩子,方便订阅等插件在此渲染选项
|
||||||
|
do_action('woocommerce_before_add_to_cart_button');
|
||||||
|
?>
|
||||||
|
<button type="submit" class="single_add_to_cart_button button alt" disabled><?php echo esc_html( apply_filters( 'woocommerce_product_single_add_to_cart_text', __( 'Add to Cart', 'yoone-product-bundles' ) ) ); ?></button>
|
||||||
|
<?php
|
||||||
|
// 在按钮之后触发钩子,保持与 Woo 默认模板一致
|
||||||
|
do_action('woocommerce_after_add_to_cart_button');
|
||||||
|
?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php
|
|
||||||
// 兼容其它插件(如订阅插件)在标准钩位渲染附加选项
|
|
||||||
// 这些钩子通常位于默认的 add-to-cart 模板中,这里手动插入以实现联动。
|
|
||||||
do_action('woocommerce_before_add_to_cart_button');
|
|
||||||
?>
|
|
||||||
<?php if (empty($allowed_products)) : ?>
|
<?php if (empty($allowed_products)) : ?>
|
||||||
<p><?php esc_html_e('No products are available for this bundle. Please configure it in the backend.', 'yoone-product-bundles'); ?></p>
|
<p><?php esc_html_e('No products are available for this bundle. Please configure it in the backend.', 'yoone-product-bundles'); ?></p>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
|
|
@ -129,7 +169,7 @@ if (!empty($term_ids)) {
|
||||||
<?php echo wp_kses_post($item->get_price_html()); ?>
|
<?php echo wp_kses_post($item->get_price_html()); ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-quantity">
|
<div class="item-quantity">
|
||||||
<input type="number" min="0" step="1" class="yoone-bundle-qty" name="yoone_bundle_components[<?php echo esc_attr($pid); ?>]" value="0" placeholder="0" />
|
<input type="number" min="0" step="1" class="yoone-bundle-qty" name="yoone_bundle_components[<?php echo esc_attr($pid); ?>]" value="0" placeholder="0" data-unit-price="<?php echo esc_attr($item->get_price()); ?>" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
@ -138,11 +178,6 @@ if (!empty($term_ids)) {
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php
|
|
||||||
// 按照默认模板的结构,在按钮之后触发 after 钩子
|
|
||||||
do_action('woocommerce_after_add_to_cart_button');
|
|
||||||
?>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
<?php do_action('woocommerce_after_add_to_cart_form'); ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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/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/admin/class-yoone-product-bundles-admin.php';
|
||||||
require_once YOONE_PB_PATH . 'includes/frontend/class-yoone-product-bundles-frontend.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/blocks/register.php';
|
||||||
require_once YOONE_PB_PATH . 'includes/shortcodes/register.php';
|
require_once YOONE_PB_PATH . 'includes/shortcodes/register.php';
|
||||||
// 注意:Elementor 小组件文件依赖 Elementor 的类,需在 Elementor 加载后再引入,避免致命错误
|
// 注意:Elementor 小组件文件依赖 Elementor 的类,需在 Elementor 加载后再引入,避免致命错误
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue