Compare commits
No commits in common. "master" and "main" have entirely different histories.
|
|
@ -1,46 +1,11 @@
|
||||||
# OS generated files
|
# 忽略 docs 目录中的所有图片与设计源文件(防止误提交到仓库)
|
||||||
.DS_Store
|
**/*.png
|
||||||
.DS_Store?
|
**/*.jpg
|
||||||
._*
|
**/*.jpeg
|
||||||
.Spotlight-V100
|
**/*.gif
|
||||||
.Trashes
|
**/*.svg
|
||||||
ehthumbs.db
|
**/*.webp
|
||||||
Thumbs.db
|
**/*.psd
|
||||||
|
**/*.ai
|
||||||
# Composer
|
**/*.fig
|
||||||
/vendor/
|
**/*.sketch
|
||||||
composer.phar
|
|
||||||
composer.lock
|
|
||||||
|
|
||||||
# npm
|
|
||||||
node_modules/
|
|
||||||
npm-debug.log
|
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# WordPress
|
|
||||||
*.log
|
|
||||||
wp-config-local.php
|
|
||||||
wp-config.php
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.sublime-project
|
|
||||||
*.sublime-workspace
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
/phpunit.xml
|
|
||||||
/tests/_output/
|
|
||||||
/tests/_support/_generated/
|
|
||||||
|
|
||||||
# Build files
|
|
||||||
*.zip
|
|
||||||
*.tar.gz
|
|
||||||
*.rardocs/产品/*.png
|
|
||||||
|
|
@ -1,3 +1,18 @@
|
||||||
/* Yoone Product Bundles 后台样式(简版) */
|
/* Yoone Product Bundles 后台样式优化 */
|
||||||
#yoone_bundle_data .description { color: #666; display: block; margin-top: 6px; }
|
#yoone_bundle_data { padding-top: 8px; }
|
||||||
#yoone_bundle_data .form-field { margin-bottom: 12px; }
|
#yoone_bundle_data .form-field { margin-bottom: 16px; }
|
||||||
|
/* 让标签独占一行,避免窄屏换行难看 */
|
||||||
|
#yoone_bundle_data .form-field > label { display: block; font-weight: 600; margin-bottom: 6px; }
|
||||||
|
/* 描述放到下一行,颜色更柔和 */
|
||||||
|
#yoone_bundle_data .description { color: #666; display: block; margin-top: 6px; line-height: 1.4; }
|
||||||
|
/* 选择器与搜索框占满一行 */
|
||||||
|
#yoone_bundle_data .wc-product-search,
|
||||||
|
#yoone_bundle_data .wc-enhanced-select,
|
||||||
|
#yoone_bundle_data select,
|
||||||
|
#yoone_bundle_data input[type="text"],
|
||||||
|
#yoone_bundle_data input[type="number"] { width: 100% !important; max-width: 820px; }
|
||||||
|
/* Select2 容器也撑满 */
|
||||||
|
#yoone_bundle_data .select2-container { width: 100% !important; max-width: 820px; }
|
||||||
|
/* 按钮的间距 */
|
||||||
|
#yoone_bundle_data .yoone-add-all-simple-products { margin-left: 0; margin-bottom: 8px; }
|
||||||
|
#yoone_bundle_data .yoone-clear-products-list { margin-left: 8px; margin-bottom: 8px; }
|
||||||
|
|
@ -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,52 +1,175 @@
|
||||||
(function($) {
|
/* Yoone Product Bundles - Admin dynamic grouping terms
|
||||||
$(document).ready(function() {
|
* When switching grouping taxonomy (category/tag), refresh the terms select list accordingly.
|
||||||
// “一键添加所有 Simple Product” 按钮点击事件
|
*/
|
||||||
$(document).on('click', '.yoone-add-all-simple-products', function(e) {
|
// Wrap ALL logic inside the jQuery IIFE to keep variables scoped and avoid ReferenceErrors
|
||||||
e.preventDefault();
|
(function($){
|
||||||
|
$(function(){
|
||||||
|
var $tax = $('#yoone_bundle_group_taxonomy');
|
||||||
|
var $terms = $('#yoone_bundle_group_terms');
|
||||||
|
var $addAllBtn = $('.yoone-add-all-simple-products');
|
||||||
|
var $clearBtn = $('.yoone-clear-products-list');
|
||||||
|
var $productSelect = $('select[name="yoone_bundle_allowed_products[]"]');
|
||||||
|
var $selectMode = $('#yoone_bundle_select_mode');
|
||||||
|
var $productsField = $('#yoone_bundle_products_list_field');
|
||||||
|
|
||||||
var $button = $(this);
|
if (typeof YoonePBAdmin === 'undefined') {
|
||||||
var $select = $('select.wc-product-search[name="yoone_bundle_allowed_products[]"]');
|
return;
|
||||||
var nonce = $('#yoone_bundle_admin_nonce_field').val();
|
}
|
||||||
|
|
||||||
$button.prop('disabled', true).text('正在加载...');
|
function refreshTerms(tax) {
|
||||||
|
if (!tax) return;
|
||||||
|
$terms.prop('disabled', true);
|
||||||
|
$.post(YoonePBAdmin.ajax_url, {
|
||||||
|
action: 'yoone_get_taxonomy_terms',
|
||||||
|
taxonomy: tax,
|
||||||
|
security: YoonePBAdmin.security
|
||||||
|
}).done(function(resp){
|
||||||
|
if (resp && resp.success && Array.isArray(resp.data)) {
|
||||||
|
var items = resp.data;
|
||||||
|
$terms.empty();
|
||||||
|
items.forEach(function(item){
|
||||||
|
var opt = $('<option/>').val(item.id).text(item.text);
|
||||||
|
$terms.append(opt);
|
||||||
|
});
|
||||||
|
// re-init enhanced selects
|
||||||
|
$(document.body).trigger('wc-enhanced-select-init');
|
||||||
|
}
|
||||||
|
}).always(function(){
|
||||||
|
$terms.prop('disabled', false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$.ajax({
|
$tax.on('change', function(){
|
||||||
url: ajaxurl,
|
var val = $(this).val();
|
||||||
method: 'POST',
|
refreshTerms(val);
|
||||||
data: {
|
|
||||||
action: 'yoone_get_all_simple_products',
|
|
||||||
security: nonce
|
|
||||||
},
|
|
||||||
success: function(response) {
|
|
||||||
if (response.success) {
|
|
||||||
var existing_ids = $select.val() || [];
|
|
||||||
var products = response.data;
|
|
||||||
|
|
||||||
products.forEach(function(product) {
|
|
||||||
// 如果下拉列表中不存在该选项,则添加
|
|
||||||
if ($select.find('option[value="' + product.id + '"]').length === 0) {
|
|
||||||
var newOption = new Option(product.text, product.id, false, false);
|
|
||||||
$select.append(newOption);
|
|
||||||
}
|
|
||||||
// 将新获取的商品ID加入到已选中的ID数组中
|
|
||||||
if (existing_ids.indexOf(product.id.toString()) === -1) {
|
|
||||||
existing_ids.push(product.id.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 更新 select2 的选中状态
|
|
||||||
$select.val(existing_ids).trigger('change');
|
|
||||||
$button.prop('disabled', false).text('一键添加所有 Simple Product');
|
|
||||||
} else {
|
|
||||||
alert('加载失败,请重试。');
|
|
||||||
$button.prop('disabled', false).text('一键添加所有 Simple Product');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: function() {
|
|
||||||
alert('请求失败,请检查网络连接或联系管理员。');
|
|
||||||
$button.prop('disabled', false).text('一键添加所有 Simple Product');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function updateProductsListState() {
|
||||||
|
var mode = $selectMode.val();
|
||||||
|
var isAll = (mode === 'all');
|
||||||
|
// 在 All 模式下,隐藏整个选择框字段;其它模式显示并更新标签文案
|
||||||
|
if ($productsField.length) {
|
||||||
|
$productsField.toggle(!isAll);
|
||||||
|
var $label = $productsField.find('> label');
|
||||||
|
if ($label.length) {
|
||||||
|
$label.text(mode === 'exclude' ? 'Excluded Products' : 'Included Products');
|
||||||
|
}
|
||||||
|
var $desc = $productsField.find('.yoone-bundle-list-desc');
|
||||||
|
if ($desc.length) {
|
||||||
|
if (mode === 'exclude') {
|
||||||
|
$desc.text('Simple products only. In Exclude mode, ALL published simple products are available EXCEPT the listed ones.');
|
||||||
|
} else {
|
||||||
|
$desc.text('Simple products only. In Include mode, ONLY the listed products will be available.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 额外:在 All 模式下禁用按钮与选择框(避免误操作)
|
||||||
|
$productSelect.prop('disabled', isAll);
|
||||||
|
$addAllBtn.prop('disabled', isAll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all simple products to the products select
|
||||||
|
if ($addAllBtn.length && $productSelect.length) {
|
||||||
|
$addAllBtn.on('click', function(){
|
||||||
|
var $btn = $(this);
|
||||||
|
var mode = $selectMode.val();
|
||||||
|
if (mode === 'exclude') {
|
||||||
|
// 在排除模式下“添加全部”会导致有效商品为空,先弹出确认。
|
||||||
|
var ok = window.confirm('You are in Exclude mode. Adding ALL simple products to the exclusion list will result in NO effective products. Continue?');
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
$btn.prop('disabled', true);
|
||||||
|
$productSelect.prop('disabled', true);
|
||||||
|
$.post(YoonePBAdmin.ajax_url, {
|
||||||
|
action: 'yoone_get_all_simple_products',
|
||||||
|
security: YoonePBAdmin.security
|
||||||
|
}).done(function(resp){
|
||||||
|
if (resp && resp.success && Array.isArray(resp.data)) {
|
||||||
|
// Clear then add all as selected
|
||||||
|
$productSelect.empty();
|
||||||
|
resp.data.forEach(function(item){
|
||||||
|
var existing = $productSelect.find('option[value="'+item.id+'"]');
|
||||||
|
if (!existing.length) {
|
||||||
|
var opt = $('<option/>').val(item.id).text(item.text).prop('selected', true);
|
||||||
|
$productSelect.append(opt);
|
||||||
|
} else {
|
||||||
|
existing.prop('selected', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Trigger change for select2/Woo product search
|
||||||
|
$productSelect.trigger('change');
|
||||||
|
}
|
||||||
|
}).always(function(){
|
||||||
|
$btn.prop('disabled', false);
|
||||||
|
$productSelect.prop('disabled', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear products list quickly
|
||||||
|
if ($clearBtn.length && $productSelect.length) {
|
||||||
|
$clearBtn.on('click', function(){
|
||||||
|
$productSelect.find('option').prop('selected', false);
|
||||||
|
// For select2/Woo product search, simply trigger change after clearing selection
|
||||||
|
$productSelect.val([]).trigger('change');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEffectiveList(items, count) {
|
||||||
|
var $list = $('#yoone_effective_products_list');
|
||||||
|
var $count = $('.yoone-effective-count');
|
||||||
|
if (!$list.length || !$count.length) return;
|
||||||
|
$count.text(count || 0);
|
||||||
|
var html = '';
|
||||||
|
if (items && items.length) {
|
||||||
|
html += '<ul class="yoone-effective-list" style="max-height:220px; overflow:auto; margin:0; padding-left:1.5em; border:1px solid #ddd; background:#fff;">';
|
||||||
|
items.forEach(function(item){
|
||||||
|
html += '<li>' + $('<div/>').text(item.text).html() + ' <span style="color:#999;">(#' + item.id + ')</span></li>';
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
} else {
|
||||||
|
html += '<p class="description">No products will be available with the current configuration.</p>';
|
||||||
|
}
|
||||||
|
$list.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeEffective() {
|
||||||
|
if (typeof YoonePBAdmin === 'undefined') return;
|
||||||
|
var mode = $selectMode.val();
|
||||||
|
var allowed = $productSelect.val() || [];
|
||||||
|
$.post(YoonePBAdmin.ajax_url, {
|
||||||
|
action: 'yoone_calc_effective_products',
|
||||||
|
security: YoonePBAdmin.security,
|
||||||
|
select_mode: mode,
|
||||||
|
allowed_products: allowed
|
||||||
|
}).done(function(resp){
|
||||||
|
if (resp && resp.success && resp.data) {
|
||||||
|
renderEffectiveList(resp.data.items || [], resp.data.count || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化:根据当前模式更新显示并首次计算有效列表
|
||||||
|
updateProductsListState();
|
||||||
|
recomputeEffective();
|
||||||
|
|
||||||
|
// 事件:模式切换与列表变更时重新计算
|
||||||
|
if ($selectMode.length) {
|
||||||
|
$selectMode.on('change', function(){
|
||||||
|
updateProductsListState();
|
||||||
|
recomputeEffective();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ($productSelect.length) {
|
||||||
|
$productSelect.on('change', recomputeEffective);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新按钮:手动触发重新计算
|
||||||
|
var $refresh = $('.yoone-refresh-effective-products');
|
||||||
|
if ($refresh.length) {
|
||||||
|
$refresh.on('click', function(){
|
||||||
|
recomputeEffective();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
})(jQuery);
|
})(jQuery);
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* Gutenberg Block: Yoone Bundle Selector
|
||||||
|
* 在产品页插入混装产品选择与加购表单。
|
||||||
|
*/
|
||||||
|
(function(wp){
|
||||||
|
const { registerBlockType } = wp.blocks;
|
||||||
|
const { __ } = wp.i18n;
|
||||||
|
const { InspectorControls } = wp.blockEditor || wp.editor;
|
||||||
|
const { PanelBody, ToggleControl, TextControl } = wp.components;
|
||||||
|
|
||||||
|
registerBlockType('yoone/bundle-selector', {
|
||||||
|
title: __('Yoone Bundle Selector', 'yoone-product-bundles'),
|
||||||
|
icon: 'cart',
|
||||||
|
category: 'widgets',
|
||||||
|
attributes: {
|
||||||
|
useCurrentProduct: { type: 'boolean', default: true },
|
||||||
|
productId: { type: 'number', default: 0 },
|
||||||
|
},
|
||||||
|
description: __('在产品页显示“混装产品列表与加购”表单;可选择当前产品或指定产品ID。', 'yoone-product-bundles'),
|
||||||
|
edit: (props) => {
|
||||||
|
const { attributes, setAttributes } = props;
|
||||||
|
const { useCurrentProduct, productId } = attributes;
|
||||||
|
return (
|
||||||
|
wp.element.createElement('div', { className: 'yoone-pb-block-editor' },
|
||||||
|
wp.element.createElement(InspectorControls, null,
|
||||||
|
wp.element.createElement(PanelBody, { title: __('设置', 'yoone-product-bundles'), initialOpen: true },
|
||||||
|
wp.element.createElement(ToggleControl, {
|
||||||
|
label: __('使用当前产品页面', 'yoone-product-bundles'),
|
||||||
|
checked: !!useCurrentProduct,
|
||||||
|
onChange: (val) => setAttributes({ useCurrentProduct: !!val })
|
||||||
|
}),
|
||||||
|
!useCurrentProduct && wp.element.createElement(TextControl, {
|
||||||
|
label: __('指定产品ID(Mix and Match 类型)', 'yoone-product-bundles'),
|
||||||
|
type: 'number',
|
||||||
|
value: productId || 0,
|
||||||
|
onChange: (val) => setAttributes({ productId: parseInt(val || '0', 10) || 0 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
wp.element.createElement('div', { className: 'yoone-pb-block-preview' },
|
||||||
|
wp.element.createElement('p', null, __('Yoone Bundle Selector(编辑器预览)', 'yoone-product-bundles')),
|
||||||
|
wp.element.createElement('p', null, useCurrentProduct
|
||||||
|
? __('将渲染当前产品的混装选择表单(需为 Mix and Match 类型)。', 'yoone-product-bundles')
|
||||||
|
: __('将渲染指定产品ID的混装选择表单。', 'yoone-product-bundles')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
save: () => null, // 由后端动态渲染
|
||||||
|
});
|
||||||
|
})(window.wp);
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绑定事件
|
// 绑定事件
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,18 @@ class Yoone_Product_Bundles_Admin {
|
||||||
|
|
||||||
// AJAX: 获取所有 simple product
|
// AJAX: 获取所有 simple product
|
||||||
add_action('wp_ajax_yoone_get_all_simple_products', array($this, 'ajax_get_all_simple_products'));
|
add_action('wp_ajax_yoone_get_all_simple_products', array($this, 'ajax_get_all_simple_products'));
|
||||||
|
// AJAX: 动态计算有效产品列表(根据选择模式与当前列表,不依赖已保存的配置)
|
||||||
|
add_action('wp_ajax_yoone_calc_effective_products', array($this, 'ajax_calc_effective_products'));
|
||||||
|
// AJAX: 根据所选 taxonomy 获取术语列表
|
||||||
|
add_action('wp_ajax_yoone_get_taxonomy_terms', array($this, 'ajax_get_taxonomy_terms'));
|
||||||
|
|
||||||
|
// 后台资源
|
||||||
|
add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_assets'));
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -39,10 +46,30 @@ class Yoone_Product_Bundles_Admin {
|
||||||
public function render_product_data_panel() {
|
public function render_product_data_panel() {
|
||||||
global $post;
|
global $post;
|
||||||
$product = wc_get_product($post->ID);
|
$product = wc_get_product($post->ID);
|
||||||
|
// 前端使用的“有效产品集合”等从 get_bundle_config 计算;但后台的选择框应显示“原始配置列表”(在 include 模式为包含列表,在 exclude 模式为排除列表,在 all 模式忽略该列表)。
|
||||||
$config = Yoone_Product_Bundles::get_bundle_config($product);
|
$config = Yoone_Product_Bundles::get_bundle_config($product);
|
||||||
$allowed = $config['allowed_products'];
|
// 读取原始配置列表(不应用 include/exclude/all 的推导),并仅保留 simple 产品ID。
|
||||||
|
$allowed_raw = get_post_meta($post->ID, Yoone_Product_Bundles::META_ALLOWED_PRODUCTS, true);
|
||||||
|
$allowed_raw = is_array($allowed_raw) ? array_values(array_map('absint', $allowed_raw)) : array();
|
||||||
|
if (! empty($allowed_raw)) {
|
||||||
|
$simple_only = array();
|
||||||
|
foreach ($allowed_raw as $aid) {
|
||||||
|
$p = wc_get_product($aid);
|
||||||
|
if ($p && $p->is_type('simple')) {
|
||||||
|
$simple_only[] = $aid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$allowed_raw = $simple_only;
|
||||||
|
}
|
||||||
|
// 供渲染“有效产品总数”的计算结果
|
||||||
|
$effective_allowed = isset($config['allowed_products']) ? (array) $config['allowed_products'] : array();
|
||||||
$min_qty = $config['min_qty'];
|
$min_qty = $config['min_qty'];
|
||||||
$cats = $config['categories'];
|
$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 '<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');
|
||||||
|
|
@ -50,13 +77,37 @@ class Yoone_Product_Bundles_Admin {
|
||||||
echo '<div class="options_group">';
|
echo '<div class="options_group">';
|
||||||
echo '<p>' . esc_html__('Configure the simple products that can be included in the bundle, the minimum quantity, and the category grouping for the frontend display.', 'yoone-product-bundles') . '</p>';
|
echo '<p>' . esc_html__('Configure the simple products that can be included in the bundle, the minimum quantity, and the category grouping for the frontend display.', 'yoone-product-bundles') . '</p>';
|
||||||
|
|
||||||
// Allowed products: use Woo's product search (select2), multiple
|
// Selection mode
|
||||||
echo '<p class="form-field"><label>' . esc_html__('Allowed Products', 'yoone-product-bundles') . '</label>';
|
woocommerce_wp_select(array(
|
||||||
echo '<button type="button" class="button yoone-add-all-simple-products" style="margin-left: 10px;">' . esc_html__('Add All Simple Products', 'yoone-product-bundles') . '</button>';
|
'id' => 'yoone_bundle_select_mode',
|
||||||
// Search only for products, not variations, to avoid errors
|
'name' => 'yoone_bundle_select_mode',
|
||||||
echo '<select class="wc-product-search" multiple style="width: 90%;" name="yoone_bundle_allowed_products[]" data-placeholder="' . esc_attr__('Search for simple products…', 'yoone-product-bundles') . '" data-action="woocommerce_json_search_products">';
|
'label' => __('Selection Mode', 'yoone-product-bundles'),
|
||||||
if (! empty($allowed)) {
|
'description' => __('Choose how products are selected for the bundle: include list, exclude list, or allow all simple products.', 'yoone-product-bundles'),
|
||||||
foreach ($allowed as $pid) {
|
'desc_tip' => true,
|
||||||
|
'options' => array(
|
||||||
|
'include' => __('Include (only listed products)', 'yoone-product-bundles'),
|
||||||
|
'exclude' => __('Exclude (allow all except listed)', 'yoone-product-bundles'),
|
||||||
|
'all' => __('All (allow all simple products)', 'yoone-product-bundles'),
|
||||||
|
),
|
||||||
|
'value' => $select_mode,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Allowed/Excluded products selector: always render the field and select element,
|
||||||
|
// but hide it initially when in 'all' mode so that toggling from ALL -> include/exclude works without a full reload.
|
||||||
|
$is_all = ($select_mode === 'all');
|
||||||
|
$list_label = ($select_mode === 'exclude')
|
||||||
|
? esc_html__('Excluded Products', 'yoone-product-bundles')
|
||||||
|
: esc_html__('Included Products', 'yoone-product-bundles');
|
||||||
|
echo '<p class="form-field" id="yoone_bundle_products_list_field"' . ($is_all ? ' style="display:none"' : '') . '>';
|
||||||
|
echo '<label>' . $list_label . '</label>';
|
||||||
|
echo '<button type="button" class="button yoone-add-all-simple-products">' . esc_html__('Add All Simple Products', 'yoone-product-bundles') . '</button>';
|
||||||
|
echo ' ';
|
||||||
|
echo '<button type="button" class="button yoone-clear-products-list">' . esc_html__('Clear Products List', 'yoone-product-bundles') . '</button>';
|
||||||
|
// WooCommerce product search select. We keep the select always in DOM for JS to toggle.
|
||||||
|
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">';
|
||||||
|
// 显示“原始配置列表”作为可编辑项(exclude 模式记为排除列表,include 模式记为包含列表)
|
||||||
|
if (! empty($allowed_raw)) {
|
||||||
|
foreach ($allowed_raw as $pid) {
|
||||||
$p = wc_get_product($pid);
|
$p = wc_get_product($pid);
|
||||||
if ($p) {
|
if ($p) {
|
||||||
printf('<option value="%d" selected>%s</option>', $pid, esc_html($p->get_formatted_name()));
|
printf('<option value="%d" selected>%s</option>', $pid, esc_html($p->get_formatted_name()));
|
||||||
|
|
@ -64,9 +115,36 @@ class Yoone_Product_Bundles_Admin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
echo '</select>';
|
echo '</select>';
|
||||||
echo '<span class="description">' . esc_html__('Only simple products are supported. Variable products may be supported in a future version.', 'yoone-product-bundles') . '</span>';
|
// 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 '</p>';
|
||||||
|
|
||||||
|
// 只读展示:有效产品列表与总数(基于当前配置推导)
|
||||||
|
echo '<div id="yoone_effective_products_block" class="yoone-effective-products" style="margin-top:10px;">';
|
||||||
|
echo '<div class="yoone-effective-actions" style="margin-bottom:6px;">';
|
||||||
|
echo '<button type="button" class="button yoone-refresh-effective-products">' . esc_html__('Refresh Effective Products', 'yoone-product-bundles') . '</button>';
|
||||||
|
echo '</div>';
|
||||||
|
echo '<p>' . sprintf(esc_html__('Effective products (%s):', 'yoone-product-bundles'), '<span class="yoone-effective-count">' . (int) count($effective_allowed) . '</span>') . '</p>';
|
||||||
|
echo '<div id="yoone_effective_products_list">';
|
||||||
|
if (! empty($effective_allowed)) {
|
||||||
|
echo '<ul class="yoone-effective-list" style="max-height:220px; overflow:auto; margin:0; padding-left:1.5em; border:1px solid #ddd; background:#fff;">';
|
||||||
|
foreach ($effective_allowed as $pid) {
|
||||||
|
$p = wc_get_product($pid);
|
||||||
|
if ($p) {
|
||||||
|
printf('<li>%s <span style="color:#999;">(#%d)</span></li>', esc_html($p->get_formatted_name()), (int) $pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo '</ul>';
|
||||||
|
} else {
|
||||||
|
echo '<p class="description">' . esc_html__('No products will be available with the current configuration.', 'yoone-product-bundles') . '</p>';
|
||||||
|
}
|
||||||
|
echo '</div>';
|
||||||
|
echo '</div>';
|
||||||
|
|
||||||
// Minimum bundle quantity
|
// Minimum bundle quantity
|
||||||
woocommerce_wp_text_input(array(
|
woocommerce_wp_text_input(array(
|
||||||
'id' => 'yoone_bundle_min_quantity',
|
'id' => 'yoone_bundle_min_quantity',
|
||||||
|
|
@ -78,16 +156,63 @@ class Yoone_Product_Bundles_Admin {
|
||||||
'custom_attributes' => array('min' => '0'),
|
'custom_attributes' => array('min' => '0'),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Display categories (product_cat)
|
// Enable editing bundle in cart
|
||||||
echo '<p class="form-field"><label>' . esc_html__('Display Categories', 'yoone-product-bundles') . '</label>';
|
woocommerce_wp_checkbox(array(
|
||||||
echo '<select class="wc-enhanced-select" multiple style="width: 90%;" name="yoone_bundle_categories[]" data-placeholder="' . esc_attr__('Select categories to group products by…', 'yoone-product-bundles') . '">';
|
'id' => 'yoone_bundle_edit_in_cart',
|
||||||
$terms = get_terms(array('taxonomy' => 'product_cat', 'hide_empty' => false));
|
'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',
|
||||||
|
'label' => __('Grouping Taxonomy', 'yoone-product-bundles'),
|
||||||
|
'description' => __('Choose which taxonomy to group products by: category or tag.', 'yoone-product-bundles'),
|
||||||
|
'desc_tip' => true,
|
||||||
|
'options' => array(
|
||||||
|
'product_cat' => __('Category', 'yoone-product-bundles'),
|
||||||
|
'product_tag' => __('Tag', 'yoone-product-bundles'),
|
||||||
|
),
|
||||||
|
'value' => $group_taxonomy,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Display terms of selected taxonomy
|
||||||
|
echo '<p class="form-field"><label>' . esc_html__('Display Terms', 'yoone-product-bundles') . '</label>';
|
||||||
|
echo '<select id="yoone_bundle_group_terms" class="wc-enhanced-select" multiple style="width: 100%;" name="yoone_bundle_group_terms[]" data-placeholder="' . esc_attr__('Select terms to group products by…', 'yoone-product-bundles') . '">';
|
||||||
|
$terms = get_terms(array('taxonomy' => $group_taxonomy, 'hide_empty' => false));
|
||||||
foreach ($terms as $t) {
|
foreach ($terms as $t) {
|
||||||
$selected = in_array($t->term_id, $cats, true) ? 'selected' : '';
|
$selected = in_array($t->term_id, $group_terms, true) ? 'selected' : '';
|
||||||
printf('<option value="%d" %s>%s</option>', $t->term_id, $selected, esc_html($t->name));
|
printf('<option value="%d" %s>%s</option>', $t->term_id, $selected, esc_html($t->name));
|
||||||
}
|
}
|
||||||
echo '</select>';
|
echo '</select>';
|
||||||
echo '<span class="description">' . esc_html__('Group the allowed products by category on the frontend. Only products matching the selected categories will be shown.', 'yoone-product-bundles') . '</span>';
|
echo '<span class="description">' . esc_html__('Group the allowed products by the selected taxonomy terms on the frontend. Only products matching the selected terms will be shown.', 'yoone-product-bundles') . '</span>';
|
||||||
echo '</p>';
|
echo '</p>';
|
||||||
|
|
||||||
echo '</div>'; // options_group
|
echo '</div>'; // options_group
|
||||||
|
|
@ -97,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_categories']);
|
$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
|
||||||
|
|
@ -120,10 +245,33 @@ 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));
|
||||||
|
|
||||||
// 保存 categories
|
// 保存“编辑购物车”开关
|
||||||
$cats = isset($_POST['yoone_bundle_categories']) ? (array) $_POST['yoone_bundle_categories'] : array();
|
$edit_in_cart = isset($_POST['yoone_bundle_edit_in_cart']) ? 'yes' : 'no';
|
||||||
$cats = array_values(array_filter(array_map('absint', $cats)));
|
update_post_meta($post_id, Yoone_Product_Bundles::META_EDIT_IN_CART, $edit_in_cart);
|
||||||
update_post_meta($post_id, Yoone_Product_Bundles::META_CATEGORIES, $cats);
|
|
||||||
|
// 保存折扣
|
||||||
|
$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';
|
||||||
|
update_post_meta($post_id, Yoone_Product_Bundles::META_SELECT_MODE, $select_mode);
|
||||||
|
|
||||||
|
// 保存 grouping taxonomy
|
||||||
|
$group_taxonomy = isset($_POST['yoone_bundle_group_taxonomy']) ? sanitize_text_field($_POST['yoone_bundle_group_taxonomy']) : 'product_cat';
|
||||||
|
$group_taxonomy = in_array($group_taxonomy, array('product_cat','product_tag'), true) ? $group_taxonomy : 'product_cat';
|
||||||
|
update_post_meta($post_id, Yoone_Product_Bundles::META_GROUP_TAXONOMY, $group_taxonomy);
|
||||||
|
// 保存选择的术语
|
||||||
|
$terms = isset($_POST['yoone_bundle_group_terms']) ? (array) $_POST['yoone_bundle_group_terms'] : array();
|
||||||
|
$terms = array_values(array_filter(array_map('absint', $terms)));
|
||||||
|
update_post_meta($post_id, Yoone_Product_Bundles::META_GROUP_TERMS, $terms);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -135,20 +283,139 @@ class Yoone_Product_Bundles_Admin {
|
||||||
}
|
}
|
||||||
check_ajax_referer('yoone-bundle-admin-nonce', 'security');
|
check_ajax_referer('yoone-bundle-admin-nonce', 'security');
|
||||||
|
|
||||||
$products = wc_get_products(array(
|
// 返回所有已发布的 simple 产品;包含任何目录可见性(visible/hidden/exclude-from-catalog/search)
|
||||||
'type' => 'simple',
|
$product_ids = wc_get_products(array(
|
||||||
'status' => 'publish',
|
'type' => array('simple'),
|
||||||
'limit' => -1,
|
'status' => array('publish'),
|
||||||
|
'catalog_visibility' => 'any',
|
||||||
|
'limit' => -1,
|
||||||
|
'orderby' => 'title',
|
||||||
|
'order' => 'ASC',
|
||||||
|
'return' => 'ids',
|
||||||
));
|
));
|
||||||
|
|
||||||
$results = array();
|
$results = array();
|
||||||
foreach ($products as $product) {
|
if (is_array($product_ids)) {
|
||||||
$results[] = array(
|
foreach ($product_ids as $pid) {
|
||||||
'id' => $product->get_id(),
|
$product = wc_get_product($pid);
|
||||||
'text' => $product->get_formatted_name(),
|
if ($product) {
|
||||||
);
|
$results[] = array(
|
||||||
|
'id' => (int) $pid,
|
||||||
|
'text' => $product->get_formatted_name(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wp_send_json_success($results);
|
wp_send_json_success($results);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: 动态计算有效产品列表与总数
|
||||||
|
* 输入:select_mode, allowed_products[](原始配置列表,不应用推导)
|
||||||
|
* 输出:{ items: [{id,text}], count: number }
|
||||||
|
*/
|
||||||
|
public function ajax_calc_effective_products() {
|
||||||
|
if (! current_user_can('edit_products')) {
|
||||||
|
wp_send_json_error('permission_denied', 403);
|
||||||
|
}
|
||||||
|
check_ajax_referer('yoone-bundle-admin-nonce', 'security');
|
||||||
|
|
||||||
|
$select_mode = isset($_POST['select_mode']) ? sanitize_text_field($_POST['select_mode']) : 'include';
|
||||||
|
$select_mode = in_array($select_mode, array('include','exclude','all'), true) ? $select_mode : 'include';
|
||||||
|
$allowed_raw = isset($_POST['allowed_products']) ? (array) $_POST['allowed_products'] : array();
|
||||||
|
$allowed_raw = array_values(array_filter(array_map('absint', $allowed_raw)));
|
||||||
|
// 仅保留 simple 产品ID
|
||||||
|
if (! empty($allowed_raw)) {
|
||||||
|
$simple_only = array();
|
||||||
|
foreach ($allowed_raw as $aid) {
|
||||||
|
$p = wc_get_product($aid);
|
||||||
|
if ($p && $p->is_type('simple')) {
|
||||||
|
$simple_only[] = $aid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$allowed_raw = $simple_only;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effective_ids = array();
|
||||||
|
if ($select_mode === 'include') {
|
||||||
|
$effective_ids = $allowed_raw;
|
||||||
|
} else {
|
||||||
|
// exclude/all: 先取全部 simple 产品,再按模式处理
|
||||||
|
$all_ids = wc_get_products(array(
|
||||||
|
'type' => array('simple'),
|
||||||
|
'status' => array('publish'),
|
||||||
|
'catalog_visibility' => 'any',
|
||||||
|
'limit' => -1,
|
||||||
|
'return' => 'ids',
|
||||||
|
));
|
||||||
|
$all_ids = is_array($all_ids) ? array_values(array_map('absint', $all_ids)) : array();
|
||||||
|
if ($select_mode === 'all') {
|
||||||
|
$effective_ids = $all_ids;
|
||||||
|
} else { // exclude
|
||||||
|
$effective_ids = array_values(array_diff($all_ids, $allowed_raw));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = array();
|
||||||
|
foreach ($effective_ids as $pid) {
|
||||||
|
$p = wc_get_product($pid);
|
||||||
|
if ($p) {
|
||||||
|
$items[] = array(
|
||||||
|
'id' => (int) $pid,
|
||||||
|
'text' => $p->get_formatted_name(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success(array(
|
||||||
|
'items' => $items,
|
||||||
|
'count' => count($items),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: 根据选择的 taxonomy 返回术语列表
|
||||||
|
*/
|
||||||
|
public function ajax_get_taxonomy_terms() {
|
||||||
|
if (! current_user_can('edit_products')) {
|
||||||
|
wp_send_json_error('permission_denied', 403);
|
||||||
|
}
|
||||||
|
check_ajax_referer('yoone-bundle-admin-nonce', 'security');
|
||||||
|
|
||||||
|
$taxonomy = isset($_POST['taxonomy']) ? sanitize_text_field($_POST['taxonomy']) : 'product_cat';
|
||||||
|
if (! in_array($taxonomy, array('product_cat', 'product_tag'), true)) {
|
||||||
|
wp_send_json_error(array('message' => 'invalid_taxonomy'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$terms = get_terms(array('taxonomy' => $taxonomy, 'hide_empty' => false));
|
||||||
|
$results = array();
|
||||||
|
foreach ($terms as $t) {
|
||||||
|
$results[] = array('id' => (int) $t->term_id, 'text' => $t->name);
|
||||||
|
}
|
||||||
|
wp_send_json_success($results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台资源:注入脚本以在切换分组 taxonomy 时动态刷新术语选择
|
||||||
|
*/
|
||||||
|
public function enqueue_admin_assets($hook) {
|
||||||
|
// 仅在产品编辑页面加载
|
||||||
|
if ($hook !== 'post.php' && $hook !== 'post-new.php') return;
|
||||||
|
$screen = get_current_screen();
|
||||||
|
if (! $screen || $screen->post_type !== 'product') return;
|
||||||
|
|
||||||
|
$handle_js = 'yoone-pb-admin';
|
||||||
|
wp_register_script($handle_js, plugins_url('assets/js/admin.js', dirname(__FILE__, 3) . '/yoone-product-bundles.php'), array('jquery'), '1.0.0', true);
|
||||||
|
wp_enqueue_script($handle_js);
|
||||||
|
wp_localize_script($handle_js, 'YoonePBAdmin', array(
|
||||||
|
'ajax_url' => admin_url('admin-ajax.php'),
|
||||||
|
'security' => wp_create_nonce('yoone-bundle-admin-nonce'),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Enqueue CSS for nicer spacing & layout
|
||||||
|
$handle_css = 'yoone-pb-admin-css';
|
||||||
|
wp_register_style($handle_css, plugins_url('assets/css/admin.css', dirname(__FILE__, 3) . '/yoone-product-bundles.php'), array(), '1.0.1');
|
||||||
|
wp_enqueue_style($handle_css);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 注册 Gutenberg 区块:Yoone Bundle Selector(混装产品选择与加购)。
|
||||||
|
*/
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
class Yoone_PB_Blocks {
|
||||||
|
protected static $instance = null;
|
||||||
|
|
||||||
|
public static function instance() {
|
||||||
|
if (null === self::$instance) self::$instance = new self();
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function __construct() {
|
||||||
|
add_action('init', array($this, 'register_blocks'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_blocks() {
|
||||||
|
// 注册编辑器脚本
|
||||||
|
// 计算插件主文件路径,确保 plugins_url 基准正确
|
||||||
|
$plugin_file = dirname(__DIR__) . '/yoone-product-bundles.php';
|
||||||
|
|
||||||
|
wp_register_script(
|
||||||
|
'yoone-pb-blocks',
|
||||||
|
plugins_url('assets/js/blocks/bundle-selector.js', $plugin_file),
|
||||||
|
array('wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n'),
|
||||||
|
defined('YOONE_PB_VERSION') ? YOONE_PB_VERSION : '0.1.0',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
register_block_type('yoone/bundle-selector', array(
|
||||||
|
'editor_script' => 'yoone-pb-blocks',
|
||||||
|
'render_callback' => array($this, 'render_bundle_selector_block'),
|
||||||
|
'attributes' => array(
|
||||||
|
'useCurrentProduct' => array('type' => 'boolean', 'default' => true),
|
||||||
|
'productId' => array('type' => 'integer', 'default' => 0),
|
||||||
|
),
|
||||||
|
'supports' => array('anchor' => true),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态渲染区块内容:在产品页或指定产品ID下渲染混装选择表单。
|
||||||
|
*/
|
||||||
|
public function render_bundle_selector_block($attributes, $content) {
|
||||||
|
// 仅在前端渲染;编辑器中显示占位提示
|
||||||
|
if (is_admin() && function_exists('wp_doing_ajax') && ! wp_doing_ajax()) {
|
||||||
|
return '<div class="yoone-pb-block-preview">' . esc_html__('Yoone Bundle Selector (预览):此区块在产品页前端渲染完整选择表单。', 'yoone-product-bundles') . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$use_current = ! empty($attributes['useCurrentProduct']);
|
||||||
|
$product_id = absint(isset($attributes['productId']) ? $attributes['productId'] : 0);
|
||||||
|
|
||||||
|
// 确定要渲染的产品对象
|
||||||
|
$product = null;
|
||||||
|
if ($use_current && is_singular('product')) {
|
||||||
|
global $post;
|
||||||
|
if ($post) $product = wc_get_product($post->ID);
|
||||||
|
} elseif ($product_id > 0) {
|
||||||
|
$product = wc_get_product($product_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全检查:仅对我们定义的混装产品类型渲染表单
|
||||||
|
if (! $product || $product->get_type() !== Yoone_Product_Bundles::TYPE) {
|
||||||
|
return '<div class="yoone-pb-block-notice">' . esc_html__('请选择或切换到一个“Mix and Match (Yoone Bundle)”产品以显示表单。', 'yoone-product-bundles') . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前端资源
|
||||||
|
wp_enqueue_style('yoone-pb-frontend');
|
||||||
|
wp_enqueue_script('yoone-pb-frontend');
|
||||||
|
|
||||||
|
// 复用插件模板输出完整表单
|
||||||
|
ob_start();
|
||||||
|
wc_get_template('global/yoone-bundle-form.php', array(), '', YOONE_PB_PATH . 'templates/');
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,18 @@ 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_CATEGORIES = '_yoone_bundle_categories'; // array<int> product_cat term_ids
|
// 是否允许在购物车中编辑混装(移除子项、调整数量等)
|
||||||
|
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)。
|
||||||
|
const META_CATEGORIES = '_yoone_bundle_categories'; // array<int> product_cat term_ids(兼容旧数据)
|
||||||
|
// 新版:支持按 taxonomy(product_cat 或 product_tag)进行分组显示
|
||||||
|
const META_GROUP_TAXONOMY = '_yoone_bundle_group_taxonomy'; // 'product_cat' | 'product_tag'
|
||||||
|
const META_GROUP_TERMS = '_yoone_bundle_group_terms'; // array<int> 选中的术语ID(来自所选taxonomy)
|
||||||
|
|
||||||
protected static $instance = null;
|
protected static $instance = null;
|
||||||
|
|
||||||
|
|
@ -32,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,11 +64,22 @@ class Yoone_Product_Bundles {
|
||||||
*/
|
*/
|
||||||
public static function get_bundle_config($product) {
|
public static function get_bundle_config($product) {
|
||||||
$product = is_numeric($product) ? wc_get_product($product) : $product;
|
$product = is_numeric($product) ? wc_get_product($product) : $product;
|
||||||
if (! $product) return array('allowed_products' => array(), 'min_qty' => 0, 'categories' => array());
|
if (! $product) return array('allowed_products' => array(), 'min_qty' => 0, 'group_taxonomy' => 'product_cat', 'group_terms' => array());
|
||||||
$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));
|
||||||
$cats = get_post_meta($pid, self::META_CATEGORIES, 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';
|
||||||
|
// 读取新的分组设置
|
||||||
|
$group_tax = get_post_meta($pid, self::META_GROUP_TAXONOMY, true);
|
||||||
|
$group_terms = get_post_meta($pid, self::META_GROUP_TERMS, true);
|
||||||
|
$group_tax = in_array($group_tax, array('product_cat','product_tag'), true) ? $group_tax : 'product_cat';
|
||||||
// Keep only simple products (to avoid issues if variations or other types were selected in the backend)
|
// Keep only simple products (to avoid issues if variations or other types were selected in the backend)
|
||||||
$allowed = is_array($allowed) ? array_values(array_map('absint', $allowed)) : array();
|
$allowed = is_array($allowed) ? array_values(array_map('absint', $allowed)) : array();
|
||||||
if (! empty($allowed)) {
|
if (! empty($allowed)) {
|
||||||
|
|
@ -70,11 +92,41 @@ class Yoone_Product_Bundles {
|
||||||
}
|
}
|
||||||
$allowed = $simple_only;
|
$allowed = $simple_only;
|
||||||
}
|
}
|
||||||
$cats = is_array($cats) ? array_values(array_map('absint', $cats)) : array();
|
// 根据选择模式计算最终 allowed 列表
|
||||||
|
if ($select_mode === 'all' || $select_mode === 'exclude') {
|
||||||
|
// 获取所有 simple 产品ID(包含任何目录可见性)
|
||||||
|
$all_ids = wc_get_products(array(
|
||||||
|
'type' => array('simple'),
|
||||||
|
'status' => array('publish'),
|
||||||
|
'catalog_visibility' => 'any',
|
||||||
|
'limit' => -1,
|
||||||
|
'return' => 'ids',
|
||||||
|
));
|
||||||
|
$all_ids = is_array($all_ids) ? array_values(array_map('absint', $all_ids)) : array();
|
||||||
|
if ($select_mode === 'all') {
|
||||||
|
$allowed = $all_ids; // 全部 simple 产品
|
||||||
|
} else {
|
||||||
|
// exclude 模式:从全部 simple 产品中排除配置列表
|
||||||
|
$allowed = array_values(array_diff($all_ids, $allowed));
|
||||||
|
}
|
||||||
|
} // include 模式:保持 allowed 原样
|
||||||
|
// 兼容旧版:如果未配置新术语,但存在旧的分类设置,则沿用分类
|
||||||
|
if (empty($group_terms)) {
|
||||||
|
$cats = get_post_meta($pid, self::META_CATEGORIES, true);
|
||||||
|
$group_terms = is_array($cats) ? array_values(array_map('absint', $cats)) : array();
|
||||||
|
$group_tax = 'product_cat';
|
||||||
|
} else {
|
||||||
|
$group_terms = is_array($group_terms) ? array_values(array_map('absint', $group_terms)) : array();
|
||||||
|
}
|
||||||
return array(
|
return array(
|
||||||
'allowed_products' => $allowed,
|
'allowed_products' => $allowed,
|
||||||
'min_qty' => max(0, $min),
|
'min_qty' => max(0, $min),
|
||||||
'categories' => $cats,
|
'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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Elementor Widget: Yoone Bundle Selector
|
||||||
|
* 在产品页或指定产品ID渲染混装产品选择与加购表单。
|
||||||
|
*/
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
// 如果 Elementor 未加载,其父类不存在,避免在文件被提前引入时产生致命错误
|
||||||
|
if (class_exists('\\Elementor\\Widget_Base')) {
|
||||||
|
if (! class_exists('Yoone_PB_Elementor_Widget')) {
|
||||||
|
class Yoone_PB_Elementor_Widget extends \Elementor\Widget_Base {
|
||||||
|
public function get_name() { return 'yoone_pb_bundle_selector'; }
|
||||||
|
public function get_title() { return __('Yoone Bundle Selector', 'yoone-product-bundles'); }
|
||||||
|
public function get_icon() { return 'eicon-cart'; }
|
||||||
|
public function get_categories() { return array('general'); }
|
||||||
|
|
||||||
|
protected function _register_controls() {
|
||||||
|
$this->start_controls_section('section_settings', array('label' => __('设置', 'yoone-product-bundles')));
|
||||||
|
|
||||||
|
$this->add_control('use_current_product', array(
|
||||||
|
'label' => __('使用当前产品页面', 'yoone-product-bundles'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('是', 'yoone-product-bundles'),
|
||||||
|
'label_off' => __('否', 'yoone-product-bundles'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->add_control('product_id', array(
|
||||||
|
'label' => __('指定产品ID(Mix and Match 类型)', 'yoone-product-bundles'),
|
||||||
|
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||||
|
'default' => 0,
|
||||||
|
'condition' => array('use_current_product!' => 'yes'),
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render() {
|
||||||
|
$settings = $this->get_settings_for_display();
|
||||||
|
$use_current = isset($settings['use_current_product']) && $settings['use_current_product'] === 'yes';
|
||||||
|
$product_id = absint(isset($settings['product_id']) ? $settings['product_id'] : 0);
|
||||||
|
|
||||||
|
// 确定产品对象
|
||||||
|
$product = null;
|
||||||
|
if ($use_current && is_singular('product')) {
|
||||||
|
global $post; if ($post) $product = wc_get_product($post->ID);
|
||||||
|
} elseif ($product_id > 0) {
|
||||||
|
$product = wc_get_product($product_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $product || $product->get_type() !== Yoone_Product_Bundles::TYPE) {
|
||||||
|
echo '<div class="yoone-pb-elementor-notice">' . esc_html__('请选择或切换到一个“Mix and Match (Yoone Bundle)”产品以显示表单。', 'yoone-product-bundles') . '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前端资源
|
||||||
|
wp_enqueue_style('yoone-pb-frontend');
|
||||||
|
wp_enqueue_script('yoone-pb-frontend');
|
||||||
|
|
||||||
|
// 渲染模板
|
||||||
|
wc_get_template('global/yoone-bundle-form.php', array(), '', YOONE_PB_PATH . 'templates/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register() {
|
||||||
|
if (! class_exists('Elementor\\Plugin')) return;
|
||||||
|
add_action('elementor/widgets/register', function($widgets_manager) {
|
||||||
|
$widgets_manager->register(new self());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 定义一个降级的空壳类,至少保证调用 register() 不会报错
|
||||||
|
if (! class_exists('Yoone_PB_Elementor_Widget')) {
|
||||||
|
class Yoone_PB_Elementor_Widget {
|
||||||
|
public static function register() { /* noop: Elementor 未加载*/ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 注册短代码: [yoone_bundle_selector use_current_product="yes" product_id="123"]
|
||||||
|
* 作用:在任何页面/文章中插入混装产品选择与加购表单。
|
||||||
|
*/
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
if (! class_exists('Yoone_PB_Shortcodes')) {
|
||||||
|
class Yoone_PB_Shortcodes {
|
||||||
|
public static function init() {
|
||||||
|
add_action('init', array(__CLASS__, 'register_shortcodes'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_shortcodes() {
|
||||||
|
add_shortcode('yoone_bundle_selector', array(__CLASS__, 'shortcode_bundle_selector'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染短代码内容
|
||||||
|
* @param array $atts
|
||||||
|
* @param string|null $content
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function shortcode_bundle_selector($atts, $content = null) {
|
||||||
|
$atts = shortcode_atts(array(
|
||||||
|
'use_current_product' => 'yes', // yes|no
|
||||||
|
'product_id' => 0,
|
||||||
|
), $atts, 'yoone_bundle_selector');
|
||||||
|
|
||||||
|
$use_current = strtolower($atts['use_current_product']) === 'yes' || $atts['use_current_product'] === '1' || $atts['use_current_product'] === 1;
|
||||||
|
$product_id = absint($atts['product_id']);
|
||||||
|
|
||||||
|
// 解析产品对象
|
||||||
|
$product = null;
|
||||||
|
if ($use_current && function_exists('is_singular') && is_singular('product')) {
|
||||||
|
global $post; if ($post) $product = wc_get_product($post->ID);
|
||||||
|
} elseif ($product_id > 0) {
|
||||||
|
$product = wc_get_product($product_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全检查:仅渲染我们定义的混装产品类型
|
||||||
|
if (! $product || ! method_exists($product, 'get_type') || $product->get_type() !== Yoone_Product_Bundles::TYPE) {
|
||||||
|
return '<div class="yoone-pb-shortcode-notice">' . esc_html__('请选择或传入一个 “Mix and Match (Yoone Bundle)” 产品以显示表单。', 'yoone-product-bundles') . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前端资源
|
||||||
|
wp_enqueue_style('yoone-pb-frontend');
|
||||||
|
wp_enqueue_script('yoone-pb-frontend');
|
||||||
|
|
||||||
|
// 输出模板
|
||||||
|
ob_start();
|
||||||
|
wc_get_template('global/yoone-bundle-form.php', array(), '', YOONE_PB_PATH . 'templates/');
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动短代码注册
|
||||||
|
Yoone_PB_Shortcodes::init();
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,21 @@ global $product;
|
||||||
// --- 数据准备逻辑 ---
|
// --- 数据准备逻辑 ---
|
||||||
$config = Yoone_Product_Bundles::get_bundle_config($product);
|
$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';
|
||||||
$min_qty = max(0, absint($config['min_qty']));
|
$min_qty = max(0, absint($config['min_qty']));
|
||||||
$cat_ids = $config['categories'];
|
// 是否允许在购物车中编辑混装
|
||||||
|
$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();
|
||||||
|
|
||||||
$allowed_products = array();
|
$allowed_products = array();
|
||||||
foreach ($allowed as $pid) {
|
foreach ($allowed as $pid) {
|
||||||
|
|
@ -26,16 +39,35 @@ foreach ($allowed as $pid) {
|
||||||
$allowed_products[$pid] = $p;
|
$allowed_products[$pid] = $p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 兜底:如果为 exclude/all 模式,但由于缓存或其他原因导致 allowed_products 为空,则直接读取所有 simple 产品
|
||||||
|
if (empty($allowed_products) && in_array($select_mode, array('exclude','all'), true)) {
|
||||||
|
$all_ids = wc_get_products(array(
|
||||||
|
'type' => array('simple'),
|
||||||
|
'status' => array('publish'),
|
||||||
|
'catalog_visibility' => 'any',
|
||||||
|
'limit' => -1,
|
||||||
|
'return' => 'ids',
|
||||||
|
));
|
||||||
|
if (is_array($all_ids)) {
|
||||||
|
foreach ($all_ids as $pid) {
|
||||||
|
$p = wc_get_product($pid);
|
||||||
|
if ($p && $p->is_type('simple')) {
|
||||||
|
$allowed_products[$pid] = $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$groups = array();
|
$groups = array();
|
||||||
if (!empty($cat_ids)) {
|
if (!empty($term_ids)) {
|
||||||
|
$is_grouped = true;
|
||||||
$others = array();
|
$others = array();
|
||||||
foreach ($allowed_products as $pid => $p) {
|
foreach ($allowed_products as $pid => $p) {
|
||||||
$terms = get_the_terms($pid, 'product_cat');
|
$terms = get_the_terms($pid, $group_taxonomy);
|
||||||
$matched = false;
|
$matched = false;
|
||||||
if (is_array($terms)) {
|
if (is_array($terms)) {
|
||||||
foreach ($terms as $t) {
|
foreach ($terms as $t) {
|
||||||
if (in_array($t->term_id, $cat_ids, true)) {
|
if (in_array($t->term_id, $term_ids, true)) {
|
||||||
if (!isset($groups[$t->term_id])) {
|
if (!isset($groups[$t->term_id])) {
|
||||||
$groups[$t->term_id] = array('term' => $t, 'items' => array());
|
$groups[$t->term_id] = array('term' => $t, 'items' => array());
|
||||||
}
|
}
|
||||||
|
|
@ -48,19 +80,56 @@ if (!empty($cat_ids)) {
|
||||||
}
|
}
|
||||||
if (!empty($others)) $groups[0] = array('term' => null, 'items' => $others);
|
if (!empty($others)) $groups[0] = array('term' => null, 'items' => $others);
|
||||||
} else {
|
} else {
|
||||||
|
$is_grouped = false;
|
||||||
$groups[0] = array('term' => null, 'items' => array_values($allowed_products));
|
$groups[0] = array('term' => null, 'items' => array_values($allowed_products));
|
||||||
}
|
}
|
||||||
// --- 数据准备逻辑结束 ---
|
// --- 数据准备逻辑结束 ---
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<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 if (empty($allowed_products)) : ?>
|
<?php if (empty($allowed_products)) : ?>
|
||||||
|
|
@ -71,7 +140,11 @@ if (!empty($cat_ids)) {
|
||||||
<?php if (!empty($group['term'])) : ?>
|
<?php if (!empty($group['term'])) : ?>
|
||||||
<h3 class="yoone-bundle-group-title"><?php echo esc_html($group['term']->name); ?></h3>
|
<h3 class="yoone-bundle-group-title"><?php echo esc_html($group['term']->name); ?></h3>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<h3 class="yoone-bundle-group-title"><?php esc_html_e('Available Products', 'yoone-product-bundles'); ?></h3>
|
<h3 class="yoone-bundle-group-title">
|
||||||
|
<?php echo $is_grouped
|
||||||
|
? esc_html__('Others', 'yoone-product-bundles')
|
||||||
|
: esc_html__('All Products', 'yoone-product-bundles'); ?>
|
||||||
|
</h3>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (empty($group['items'])) : ?>
|
<?php if (empty($group['items'])) : ?>
|
||||||
|
|
@ -96,7 +169,7 @@ if (!empty($cat_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; ?>
|
||||||
|
|
@ -105,6 +178,6 @@ if (!empty($cat_ids)) {
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
<?php do_action('woocommerce_after_add_to_cart_form'); ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
* Version: 0.1.0
|
* Version: 0.1.0
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
|
* Requires Plugins: woocommerce
|
||||||
* WC requires at least: 6.0
|
* WC requires at least: 6.0
|
||||||
* WC tested up to: 8.x
|
* WC tested up to: 8.x
|
||||||
*/
|
*/
|
||||||
|
|
@ -29,6 +30,11 @@ 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/shortcodes/register.php';
|
||||||
|
// 注意:Elementor 小组件文件依赖 Elementor 的类,需在 Elementor 加载后再引入,避免致命错误
|
||||||
|
|
||||||
// 引导插件
|
// 引导插件
|
||||||
add_action('plugins_loaded', function () {
|
add_action('plugins_loaded', function () {
|
||||||
|
|
@ -36,6 +42,17 @@ add_action('plugins_loaded', function () {
|
||||||
Yoone_Product_Bundles::instance();
|
Yoone_Product_Bundles::instance();
|
||||||
Yoone_Product_Bundles_Admin::instance();
|
Yoone_Product_Bundles_Admin::instance();
|
||||||
Yoone_Product_Bundles_Frontend::instance();
|
Yoone_Product_Bundles_Frontend::instance();
|
||||||
|
// 注册 Gutenberg 区块
|
||||||
|
if (function_exists('register_block_type')) {
|
||||||
|
Yoone_PB_Blocks::instance();
|
||||||
|
}
|
||||||
|
// 注册 Elementor 小组件(在 Elementor 通知 widgets 可注册时再加载并注册)
|
||||||
|
add_action('elementor/widgets/register', function($widgets_manager){
|
||||||
|
require_once YOONE_PB_PATH . 'includes/elementor/class-yoone-pb-elementor-widget.php';
|
||||||
|
if (class_exists('Yoone_PB_Elementor_Widget') && class_exists('\\Elementor\\Widget_Base')) {
|
||||||
|
$widgets_manager->register(new Yoone_PB_Elementor_Widget());
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 插件版本号
|
// 插件版本号
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue