feat(前端): 重构混装产品前端逻辑并优化购物车处理
重构混装产品的前端逻辑,改用模板部分替换完整页面模板 优化购物车处理流程,实现容器与子项关联管理 移除旧版混装产品单页模板,新增全局混装表单模板
This commit is contained in:
parent
02d7cf85df
commit
1746bb5dd2
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 后台:为混装产品提供配置界面(可混装商品、最小数量、分类)。
|
* Admin: Provides the configuration interface for bundle products (allowed products, min quantity, categories).
|
||||||
*/
|
*/
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
|
@ -28,9 +28,9 @@ class Yoone_Product_Bundles_Admin {
|
||||||
|
|
||||||
public function add_product_data_tab($tabs) {
|
public function add_product_data_tab($tabs) {
|
||||||
$tabs['yoone_bundle'] = array(
|
$tabs['yoone_bundle'] = array(
|
||||||
'label' => __('混装产品', 'yoone-product-bundles'),
|
'label' => __('Mix and Match', 'yoone-product-bundles'),
|
||||||
'target' => 'yoone_bundle_data',
|
'target' => 'yoone_bundle_data',
|
||||||
'class' => array('show_if_yoone_bundle'), // 仅当类型为 yoone_bundle 时显示
|
'class' => array('show_if_yoone_bundle'), // Only show for 'yoone_bundle' type
|
||||||
'priority' => 70,
|
'priority' => 70,
|
||||||
);
|
);
|
||||||
return $tabs;
|
return $tabs;
|
||||||
|
|
@ -48,13 +48,13 @@ class Yoone_Product_Bundles_Admin {
|
||||||
wp_nonce_field('yoone-bundle-admin-nonce', 'yoone_bundle_admin_nonce_field');
|
wp_nonce_field('yoone-bundle-admin-nonce', 'yoone_bundle_admin_nonce_field');
|
||||||
|
|
||||||
echo '<div class="options_group">';
|
echo '<div class="options_group">';
|
||||||
echo '<p>' . esc_html__('配置可混装的简单商品、最小混装数量以及前端展示的分类分组。', '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>';
|
||||||
|
|
||||||
// 可混装商品:使用 Woo 的产品搜索(select2),multiple
|
// Allowed products: use Woo's product search (select2), multiple
|
||||||
echo '<p class="form-field"><label>' . esc_html__('可混装商品', 'yoone-product-bundles') . '</label>';
|
echo '<p class="form-field"><label>' . esc_html__('Allowed Products', 'yoone-product-bundles') . '</label>';
|
||||||
echo '<button type="button" class="button yoone-add-all-simple-products" style="margin-left: 10px;">' . esc_html__('一键添加所有 Simple Product', 'yoone-product-bundles') . '</button>';
|
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>';
|
||||||
// 仅搜索产品,不含变体,避免误选导致前端不显示
|
// Search only for products, not variations, to avoid errors
|
||||||
echo '<select class="wc-product-search" multiple style="width: 90%;" name="yoone_bundle_allowed_products[]" data-placeholder="' . esc_attr__('选择可混装的简单商品…', 'yoone-product-bundles') . '" data-action="woocommerce_json_search_products">';
|
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">';
|
||||||
if (! empty($allowed)) {
|
if (! empty($allowed)) {
|
||||||
foreach ($allowed as $pid) {
|
foreach ($allowed as $pid) {
|
||||||
$p = wc_get_product($pid);
|
$p = wc_get_product($pid);
|
||||||
|
|
@ -64,30 +64,30 @@ class Yoone_Product_Bundles_Admin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
echo '</select>';
|
echo '</select>';
|
||||||
echo '<span class="description">' . esc_html__('仅支持 simple product;变体商品可在后续版本支持。', 'yoone-product-bundles') . '</span>';
|
echo '<span class="description">' . esc_html__('Only simple products are supported. Variable products may be supported in a future version.', 'yoone-product-bundles') . '</span>';
|
||||||
echo '</p>';
|
echo '</p>';
|
||||||
|
|
||||||
// 最小混装数量
|
// Minimum bundle quantity
|
||||||
woocommerce_wp_text_input(array(
|
woocommerce_wp_text_input(array(
|
||||||
'id' => 'yoone_bundle_min_quantity',
|
'id' => 'yoone_bundle_min_quantity',
|
||||||
'label' => __('最小混装数量', 'yoone-product-bundles'),
|
'label' => __('Minimum Quantity', 'yoone-product-bundles'),
|
||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
'desc_tip' => true,
|
'desc_tip' => true,
|
||||||
'description' => __('顾客选择的总数量需不小于该值,方可加入购物车。', 'yoone-product-bundles'),
|
'description' => __('The total quantity of selected items must be greater than or equal to this value to add to the cart.', 'yoone-product-bundles'),
|
||||||
'value' => $min_qty,
|
'value' => $min_qty,
|
||||||
'custom_attributes' => array('min' => '0'),
|
'custom_attributes' => array('min' => '0'),
|
||||||
));
|
));
|
||||||
|
|
||||||
// 展示分类(product_cat)
|
// Display categories (product_cat)
|
||||||
echo '<p class="form-field"><label>' . esc_html__('前端展示分类', 'yoone-product-bundles') . '</label>';
|
echo '<p class="form-field"><label>' . esc_html__('Display Categories', 'yoone-product-bundles') . '</label>';
|
||||||
echo '<select class="wc-enhanced-select" multiple style="width: 90%;" name="yoone_bundle_categories[]" data-placeholder="' . esc_attr__('选择用于分组展示的分类…', 'yoone-product-bundles') . '">';
|
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') . '">';
|
||||||
$terms = get_terms(array('taxonomy' => 'product_cat', 'hide_empty' => false));
|
$terms = get_terms(array('taxonomy' => 'product_cat', '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, $cats, 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__('用于在前端页面按分类分组展示可混装商品(仅展示与所选分类匹配的可混装商品)。', 'yoone-product-bundles') . '</span>';
|
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 '</p>';
|
echo '</p>';
|
||||||
|
|
||||||
echo '</div>'; // options_group
|
echo '</div>'; // options_group
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 核心:注册产品类型、常量、工具方法。
|
* Core: Registers the product type, constants, and utility methods.
|
||||||
*/
|
*/
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
class Yoone_Product_Bundles {
|
class Yoone_Product_Bundles {
|
||||||
const TYPE = 'yoone_bundle';
|
const TYPE = 'yoone_bundle';
|
||||||
|
|
||||||
// 配置的 postmeta 键名
|
// 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_CATEGORIES = '_yoone_bundle_categories'; // array<int> product_cat term_ids
|
||||||
|
|
@ -22,22 +22,22 @@ class Yoone_Product_Bundles {
|
||||||
}
|
}
|
||||||
|
|
||||||
private function __construct() {
|
private function __construct() {
|
||||||
// 将产品类型加入选择器
|
// Add "Mix and Match" to the "Product Type" dropdown in the admin.
|
||||||
add_filter('product_type_selector', array($this, 'register_product_type_in_selector'));
|
add_filter('product_type_selector', array($this, 'register_product_type_in_selector'));
|
||||||
// 将类型映射到我们的 WC_Product 派生类
|
// Map the type to our class name, so WooCommerce instantiates the correct product object.
|
||||||
add_filter('woocommerce_product_class', array($this, 'map_product_class'), 10, 2);
|
add_filter('woocommerce_product_class', array($this, 'map_product_class'), 10, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在后台“产品类型”下拉中添加“混装产品”。
|
* 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] = __('混装产品 (Yoone Bundle)', 'yoone-product-bundles');
|
$types[self::TYPE] = __('Mix and Match (Yoone Bundle)', 'yoone-product-bundles');
|
||||||
return $types;
|
return $types;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将类型映射到类名,WooCommerce 会实例化对应产品对象。
|
* Map the type to our class name, so WooCommerce instantiates the correct product object.
|
||||||
*/
|
*/
|
||||||
public function map_product_class($classname, $product_type) {
|
public function map_product_class($classname, $product_type) {
|
||||||
if ($product_type === self::TYPE) {
|
if ($product_type === self::TYPE) {
|
||||||
|
|
@ -47,7 +47,7 @@ class Yoone_Product_Bundles {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取并规范化 bundle 配置。
|
* Read and normalize the bundle configuration.
|
||||||
* @param int|WC_Product $product
|
* @param int|WC_Product $product
|
||||||
* @return array{allowed_products:int[],min_qty:int,categories:int[]}
|
* @return array{allowed_products:int[],min_qty:int,categories:int[]}
|
||||||
*/
|
*/
|
||||||
|
|
@ -58,7 +58,7 @@ class Yoone_Product_Bundles {
|
||||||
$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);
|
$cats = get_post_meta($pid, self::META_CATEGORIES, true);
|
||||||
// 仅保留 simple 产品(避免后台选择了变体或其他类型导致前端不显示)
|
// 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)) {
|
||||||
$simple_only = array();
|
$simple_only = array();
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 产品类型对象:WC_Product_Yoone_Bundle
|
* Product Type Object: WC_Product_Yoone_Bundle
|
||||||
* - 定义产品类型标识;
|
* - Defines the product type identifier;
|
||||||
* - 可选择设置为售卖方式(例如仅允许单件购买)。
|
* - Can be configured to set purchasing rules (e.g., sold individually).
|
||||||
*/
|
*/
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
class WC_Product_Yoone_Bundle extends WC_Product {
|
class WC_Product_Yoone_Bundle extends WC_Product {
|
||||||
public function __construct($product) {
|
public function __construct($product) {
|
||||||
parent::__construct($product);
|
parent::__construct($product);
|
||||||
$this->set_virtual(true); // 混装产品不作为单独实物发货,实际发货明细由所选组件决定(MVP 简化)
|
// A bundle product itself isn't shipped; shipping is determined by its components (MVP simplification).
|
||||||
|
$this->set_virtual(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get_type() {
|
public function get_type() {
|
||||||
|
|
@ -17,9 +18,35 @@ class WC_Product_Yoone_Bundle extends WC_Product {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 混装产品通常只允许单件购买(其内部包含多个组件)。
|
* Bundle products are typically sold individually as they contain multiple items.
|
||||||
*/
|
*/
|
||||||
public function is_sold_individually() {
|
public function is_sold_individually() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Price Override ---
|
||||||
|
|
||||||
|
public function get_price($context = 'view') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_regular_price($context = 'view') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_sale_price($context = 'view') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set_price($price) {
|
||||||
|
// Prevent price from being changed.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set_regular_price($price) {
|
||||||
|
// Prevent price from being changed.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set_sale_price($price) {
|
||||||
|
// Prevent price from being changed.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 前端:渲染混装产品的自定义 add-to-cart 表单、校验并加入购物车。
|
* 前端:为混装产品渲染自定义的“添加到购物车”表单,并处理验证和加入购物车的逻辑。
|
||||||
*/
|
*/
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
|
@ -16,53 +16,160 @@ class Yoone_Product_Bundles_Frontend {
|
||||||
}
|
}
|
||||||
|
|
||||||
private function __construct() {
|
private function __construct() {
|
||||||
// 加载自定义的混装产品页面模板
|
$this->setup_hooks();
|
||||||
add_filter('template_include', array($this, 'use_bundle_template'), 99);
|
}
|
||||||
|
|
||||||
// 处理加入购物车:校验数量并打包所选组件数据
|
/**
|
||||||
add_filter('woocommerce_add_to_cart_validation', array($this, 'validate_add_to_cart'), 10, 6);
|
* Setup all frontend-related hooks.
|
||||||
add_filter('woocommerce_add_cart_item_data', array($this, 'add_cart_item_data'), 10, 3);
|
*/
|
||||||
|
public function setup_hooks() {
|
||||||
|
// Handle the custom add-to-cart process for bundles
|
||||||
|
add_filter('woocommerce_add_to_cart_validation', array($this, 'process_bundle_add_to_cart'), 10, 3);
|
||||||
|
|
||||||
// 在购物车/订单中显示组件摘要
|
// When a bundle container is removed, remove its children.
|
||||||
add_filter('woocommerce_get_item_data', array($this, 'display_cart_item_data'), 10, 2);
|
add_action('woocommerce_cart_item_removed', array($this, 'remove_bundle_children'), 10, 2);
|
||||||
|
|
||||||
// 结算前动态调整价格
|
// Hide the "remove" link for child items in the cart.
|
||||||
add_action('woocommerce_before_calculate_totals', array($this, 'adjust_bundle_price'), 20, 1);
|
add_filter('woocommerce_cart_item_remove_link', array($this, 'hide_child_remove_link'), 10, 2);
|
||||||
|
|
||||||
|
// In the cart, show which bundle a child item belongs to.
|
||||||
|
add_filter('woocommerce_get_item_data', array($this, 'display_child_bundle_link'), 10, 2);
|
||||||
|
|
||||||
// 为混装产品页增加 body class,便于主题兼容样式控制
|
// Add a body class to the bundle product page for easier styling.
|
||||||
add_filter('body_class', array($this, 'filter_body_class'));
|
add_filter('body_class', array($this, 'filter_body_class'));
|
||||||
|
|
||||||
// 从 woocommerce_single_product_summary 钩子中移除默认的 add-to-cart
|
// Replace the default "Add to Cart" button with our custom form.
|
||||||
add_action('init', array($this, 'remove_default_add_to_cart'));
|
add_action('woocommerce_single_product_summary', array($this, 'remove_default_add_to_cart_for_bundle'), 29);
|
||||||
|
add_action('woocommerce_single_product_summary', array($this, 'render_bundle_add_to_cart_form'), 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除默认的 add-to-cart 按钮,因为我们的模板会自己处理
|
* Hijack the add-to-cart process for bundle products.
|
||||||
|
* Validates the submission, then adds the container and all child products to the cart.
|
||||||
|
*
|
||||||
|
* @return bool False to prevent the default add-to-cart action.
|
||||||
*/
|
*/
|
||||||
public function remove_default_add_to_cart() {
|
public function process_bundle_add_to_cart($passed, $product_id, $quantity) {
|
||||||
remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_add_to_cart', 30);
|
$product = wc_get_product($product_id);
|
||||||
|
if (!$product || $product->get_type() !== Yoone_Product_Bundles::TYPE) {
|
||||||
|
return $passed; // Not our product, pass through.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Validation
|
||||||
|
$config = Yoone_Product_Bundles::get_bundle_config($product);
|
||||||
|
$min_qty = max(1, absint($config['min_qty']));
|
||||||
|
$components = !empty($_POST['yoone_bundle_components']) ? (array) $_POST['yoone_bundle_components'] : array();
|
||||||
|
|
||||||
|
$total_qty = 0;
|
||||||
|
$clean_components = array();
|
||||||
|
foreach ($components as $comp_id => $qty) {
|
||||||
|
$qty = absint($qty);
|
||||||
|
if ($qty > 0) {
|
||||||
|
$total_qty += $qty;
|
||||||
|
$clean_components[absint($comp_id)] = $qty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($total_qty < $min_qty) {
|
||||||
|
wc_add_notice(sprintf(__('You need to select at least %d items to add this bundle to your cart.', 'yoone-product-bundles'), $min_qty), 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add items to cart
|
||||||
|
try {
|
||||||
|
$bundle_container_id = uniqid('bundle_');
|
||||||
|
|
||||||
|
// Add the main bundle product (as a 0-price container)
|
||||||
|
WC()->cart->add_to_cart($product_id, 1, 0, array(), array('yoone_bundle_container_id' => $bundle_container_id));
|
||||||
|
|
||||||
|
// Add child components
|
||||||
|
foreach ($clean_components as $comp_id => $qty) {
|
||||||
|
WC()->cart->add_to_cart($comp_id, $qty, 0, array(), array(
|
||||||
|
'yoone_bundle_parent_id' => $bundle_container_id,
|
||||||
|
'yoone_bundle_parent_product_id' => $product_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set success message and redirect
|
||||||
|
wc_add_to_cart_message(array($product_id => 1), true);
|
||||||
|
add_filter('woocommerce_add_to_cart_redirect', function() { return wc_get_cart_url(); });
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
wc_add_notice($e->getMessage(), 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default add-to-cart for the main product
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 如果是混装产品页面,则加载我们的自定义模板。
|
* When a cart item is removed, check if it's a bundle container.
|
||||||
|
* If so, find and remove all its child items.
|
||||||
*/
|
*/
|
||||||
public function use_bundle_template($template) {
|
public function remove_bundle_children($removed_cart_item_key, $cart) {
|
||||||
if (is_singular('product')) {
|
$removed_item = $cart->get_removed_cart_items()[$removed_cart_item_key];
|
||||||
global $post;
|
|
||||||
$product = wc_get_product($post->ID);
|
if (isset($removed_item['yoone_bundle_container_id'])) {
|
||||||
if ($product && $product->get_type() === Yoone_Product_Bundles::TYPE) {
|
$bundle_container_id = $removed_item['yoone_bundle_container_id'];
|
||||||
// 加载插件内的完整页面模板
|
|
||||||
$new_template = YOONE_PB_PATH . 'templates/single-product-yoone-bundle.php';
|
foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
|
||||||
if (file_exists($new_template)) {
|
if (isset($cart_item['yoone_bundle_parent_id']) && $cart_item['yoone_bundle_parent_id'] === $bundle_container_id) {
|
||||||
return $new_template;
|
$cart->remove_cart_item($cart_item_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $template;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在混装产品页增加 body class。
|
* Hide the remove link for child items of a bundle.
|
||||||
|
*/
|
||||||
|
public function hide_child_remove_link($link, $cart_item_key) {
|
||||||
|
$cart_item = WC()->cart->get_cart_item($cart_item_key);
|
||||||
|
if (isset($cart_item['yoone_bundle_parent_id'])) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return $link;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For child items, add a meta line in the cart to link back to the parent bundle.
|
||||||
|
*/
|
||||||
|
public function display_child_bundle_link($item_data, $cart_item) {
|
||||||
|
if (isset($cart_item['yoone_bundle_parent_product_id'])) {
|
||||||
|
$parent_product = wc_get_product($cart_item['yoone_bundle_parent_product_id']);
|
||||||
|
if ($parent_product) {
|
||||||
|
$item_data[] = array(
|
||||||
|
'key' => __('Part of', 'yoone-product-bundles'),
|
||||||
|
'value' => $parent_product->get_name(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $item_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the default "Add to Cart" button for bundle products only.
|
||||||
|
*/
|
||||||
|
public function remove_default_add_to_cart_for_bundle() {
|
||||||
|
global $product;
|
||||||
|
if ($product && $product->get_type() === Yoone_Product_Bundles::TYPE) {
|
||||||
|
remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_add_to_cart', 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the custom "Add to Cart" form for bundle products.
|
||||||
|
*/
|
||||||
|
public function render_bundle_add_to_cart_form() {
|
||||||
|
global $product;
|
||||||
|
if ($product && $product->get_type() === Yoone_Product_Bundles::TYPE) {
|
||||||
|
wc_get_template('global/yoone-bundle-form.php', array(), '', YOONE_PB_PATH . 'templates/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a body class for bundle product pages.
|
||||||
*/
|
*/
|
||||||
public function filter_body_class($classes) {
|
public function filter_body_class($classes) {
|
||||||
global $post;
|
global $post;
|
||||||
|
|
@ -74,103 +181,4 @@ class Yoone_Product_Bundles_Frontend {
|
||||||
}
|
}
|
||||||
return $classes;
|
return $classes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验混装产品加入购物车:至少选择一个组件。
|
|
||||||
*/
|
|
||||||
public function validate_add_to_cart($passed, $product_id, $quantity, $variation_id = null, $variations = null, $cart_item_data = null) {
|
|
||||||
$product = wc_get_product($product_id);
|
|
||||||
if ($product->get_type() !== Yoone_Product_Bundles::TYPE) {
|
|
||||||
return $passed;
|
|
||||||
}
|
|
||||||
|
|
||||||
$config = Yoone_Product_Bundles::get_bundle_config($product);
|
|
||||||
$min_qty = max(1, absint($config['min_qty']));
|
|
||||||
|
|
||||||
$components = !empty($_POST['yoone_bundle_components']) ? $_POST['yoone_bundle_components'] : array();
|
|
||||||
$total_qty = 0;
|
|
||||||
foreach ($components as $comp_id => $qty) {
|
|
||||||
$qty = absint($qty);
|
|
||||||
if ($qty > 0) {
|
|
||||||
$total_qty += $qty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($total_qty < $min_qty) {
|
|
||||||
wc_add_notice(sprintf(__('您需要至少选择 %d 件商品才能将此混装包加入购物车。', 'yoone-product-bundles'), $min_qty), 'error');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $passed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将混装组件数据添加到购物车项目。
|
|
||||||
*/
|
|
||||||
public function add_cart_item_data($cart_item_data, $product_id, $variation_id) {
|
|
||||||
$product = wc_get_product($product_id);
|
|
||||||
if ($product->get_type() !== Yoone_Product_Bundles::TYPE) {
|
|
||||||
return $cart_item_data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($_POST['yoone_bundle_components'])) {
|
|
||||||
$components = array();
|
|
||||||
foreach ($_POST['yoone_bundle_components'] as $comp_id => $qty) {
|
|
||||||
$qty = absint($qty);
|
|
||||||
if ($qty > 0) {
|
|
||||||
$components[absint($comp_id)] = $qty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!empty($components)) {
|
|
||||||
$cart_item_data['yoone_bundle_components'] = $components;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cart_item_data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在购物车和订单中显示混装组件的摘要。
|
|
||||||
*/
|
|
||||||
public function display_cart_item_data($item_data, $cart_item) {
|
|
||||||
if (empty($cart_item['yoone_bundle_components'])) {
|
|
||||||
return $item_data;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = "";
|
|
||||||
foreach ($cart_item['yoone_bundle_components'] as $pid => $qty) {
|
|
||||||
$product = wc_get_product($pid);
|
|
||||||
if ($product) {
|
|
||||||
$value .= $product->get_name() . ' × ' . $qty . "; ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$item_data[] = array(
|
|
||||||
'key' => __('混装明细', 'yoone-product-bundles'),
|
|
||||||
'value' => rtrim($value, "; "),
|
|
||||||
'display' => ''
|
|
||||||
);
|
|
||||||
|
|
||||||
return $item_data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在将商品添加到购物车之前,根据所选组件动态计算混装产品的总价。
|
|
||||||
*/
|
|
||||||
public function adjust_bundle_price($cart) {
|
|
||||||
if (is_admin() && !defined('DOING_AJAX')) return;
|
|
||||||
|
|
||||||
foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
|
|
||||||
if (isset($cart_item['yoone_bundle_components'])) {
|
|
||||||
$total_price = 0;
|
|
||||||
foreach ($cart_item['yoone_bundle_components'] as $pid => $qty) {
|
|
||||||
$product = wc_get_product($pid);
|
|
||||||
if ($product) {
|
|
||||||
$total_price += (float) $product->get_price() * $qty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$cart_item['data']->set_price($total_price);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Yoone Product Bundles - Mix and Match Product Selection Form
|
||||||
|
*
|
||||||
|
* This template part contains the full frontend interface for selecting bundle components,
|
||||||
|
* including the card-based layout, quantity inputs, and dynamically updated total count.
|
||||||
|
* It is hooked into `woocommerce_single_product_summary` to replace the standard add-to-cart form.
|
||||||
|
*
|
||||||
|
* @version 3.0.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
global $product;
|
||||||
|
|
||||||
|
// --- 数据准备逻辑 ---
|
||||||
|
$config = Yoone_Product_Bundles::get_bundle_config($product);
|
||||||
|
$allowed = $config['allowed_products'];
|
||||||
|
$min_qty = max(0, absint($config['min_qty']));
|
||||||
|
$cat_ids = $config['categories'];
|
||||||
|
|
||||||
|
$allowed_products = array();
|
||||||
|
foreach ($allowed as $pid) {
|
||||||
|
$p = wc_get_product($pid);
|
||||||
|
if ($p && $p->is_type('simple')) {
|
||||||
|
$allowed_products[$pid] = $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups = array();
|
||||||
|
if (!empty($cat_ids)) {
|
||||||
|
$others = array();
|
||||||
|
foreach ($allowed_products as $pid => $p) {
|
||||||
|
$terms = get_the_terms($pid, 'product_cat');
|
||||||
|
$matched = false;
|
||||||
|
if (is_array($terms)) {
|
||||||
|
foreach ($terms as $t) {
|
||||||
|
if (in_array($t->term_id, $cat_ids, true)) {
|
||||||
|
if (!isset($groups[$t->term_id])) {
|
||||||
|
$groups[$t->term_id] = array('term' => $t, 'items' => array());
|
||||||
|
}
|
||||||
|
$groups[$t->term_id]['items'][] = $p;
|
||||||
|
$matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$matched) $others[] = $p;
|
||||||
|
}
|
||||||
|
if (!empty($others)) $groups[0] = array('term' => null, 'items' => $others);
|
||||||
|
} else {
|
||||||
|
$groups[0] = array('term' => null, 'items' => array_values($allowed_products));
|
||||||
|
}
|
||||||
|
// --- 数据准备逻辑结束 ---
|
||||||
|
?>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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-selected"><?php esc_html_e('Currently selected:', 'yoone-product-bundles'); ?> <span class="yoone-bundle-selected-count">0</span></p>
|
||||||
|
<div class="yoone-bundle-actions">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if (empty($allowed_products)) : ?>
|
||||||
|
<p><?php esc_html_e('No products are available for this bundle. Please configure it in the backend.', 'yoone-product-bundles'); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<?php foreach ($groups as $gid => $group) : ?>
|
||||||
|
<div class="yoone-bundle-group">
|
||||||
|
<?php if (!empty($group['term'])) : ?>
|
||||||
|
<h3 class="yoone-bundle-group-title"><?php echo esc_html($group['term']->name); ?></h3>
|
||||||
|
<?php else : ?>
|
||||||
|
<h3 class="yoone-bundle-group-title"><?php esc_html_e('Available Products', 'yoone-product-bundles'); ?></h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (empty($group['items'])) : ?>
|
||||||
|
<p class="yoone-bundle-no-items"><?php esc_html_e('No products available in this group.', 'yoone-product-bundles'); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="yoone-bundle-items-wrapper">
|
||||||
|
<?php foreach ($group['items'] as $item) : ?>
|
||||||
|
<?php
|
||||||
|
$pid = $item->get_id();
|
||||||
|
$thumbnail = $item->get_image('woocommerce_thumbnail');
|
||||||
|
?>
|
||||||
|
<div class="yoone-bundle-item-card">
|
||||||
|
<div class="item-image">
|
||||||
|
<a href="<?php echo esc_url(get_permalink($pid)); ?>" target="_blank">
|
||||||
|
<?php echo $thumbnail ? $thumbnail : wc_placeholder_img('woocommerce_thumbnail'); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h4 class="item-title">
|
||||||
|
<a href="<?php echo esc_url(get_permalink($pid)); ?>" target="_blank"><?php echo esc_html($item->get_name()); ?></a>
|
||||||
|
</h4>
|
||||||
|
<div class="item-price">
|
||||||
|
<?php echo wp_kses_post($item->get_price_html()); ?>
|
||||||
|
</div>
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Yoone Product Bundles - 混装产品单页模板 (v2)
|
|
||||||
*
|
|
||||||
* 此模板将完全取代 WooCommerce 的默认 single-product.php,提供一个包含卡片式选项的自定义布局。
|
|
||||||
* 它通过调用 get_header(), get_footer() 和 do_action() 来确保与主题的兼容性。
|
|
||||||
*
|
|
||||||
* @version 2.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
|
||||||
|
|
||||||
global $product;
|
|
||||||
|
|
||||||
// 确保这是一个混装产品
|
|
||||||
if (!$product || $product->get_type() !== Yoone_Product_Bundles::TYPE) {
|
|
||||||
// 如果不是,则回退到标准模板
|
|
||||||
wc_get_template_part('content', 'single-product');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 数据准备逻辑 ---
|
|
||||||
$config = Yoone_Product_Bundles::get_bundle_config($product);
|
|
||||||
$allowed = $config['allowed_products'];
|
|
||||||
$min_qty = max(0, absint($config['min_qty']));
|
|
||||||
$cat_ids = $config['categories'];
|
|
||||||
|
|
||||||
$allowed_products = array();
|
|
||||||
foreach ($allowed as $pid) {
|
|
||||||
$p = wc_get_product($pid);
|
|
||||||
if ($p && $p->is_type('simple')) {
|
|
||||||
$allowed_products[$pid] = $p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$groups = array();
|
|
||||||
if (!empty($cat_ids)) {
|
|
||||||
$others = array();
|
|
||||||
foreach ($allowed_products as $pid => $p) {
|
|
||||||
$terms = get_the_terms($pid, 'product_cat');
|
|
||||||
$matched = false;
|
|
||||||
if (is_array($terms)) {
|
|
||||||
foreach ($terms as $t) {
|
|
||||||
if (in_array($t->term_id, $cat_ids, true)) {
|
|
||||||
if (!isset($groups[$t->term_id])) {
|
|
||||||
$groups[$t->term_id] = array('term' => $t, 'items' => array());
|
|
||||||
}
|
|
||||||
$groups[$t->term_id]['items'][] = $p;
|
|
||||||
$matched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!$matched) $others[] = $p;
|
|
||||||
}
|
|
||||||
if (!empty($others)) $groups[0] = array('term' => null, 'items' => $others);
|
|
||||||
} else {
|
|
||||||
$groups[0] = array('term' => null, 'items' => array_values($allowed_products));
|
|
||||||
}
|
|
||||||
// --- 数据准备逻辑结束 ---
|
|
||||||
|
|
||||||
get_header('shop');
|
|
||||||
?>
|
|
||||||
|
|
||||||
<?php do_action('woocommerce_before_main_content'); ?>
|
|
||||||
|
|
||||||
<div id="product-<?php the_ID(); ?>" <?php wc_product_class('yoone-bundle-product-page', $product); ?>>
|
|
||||||
|
|
||||||
<?php do_action('woocommerce_before_single_product_summary'); ?>
|
|
||||||
|
|
||||||
<div class="summary entry-summary">
|
|
||||||
<?php
|
|
||||||
do_action('woocommerce_single_product_summary');
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<div class="yoone-bundle-meta">
|
|
||||||
<p class="yoone-bundle-min"><?php printf(esc_html__('至少选择 %d 件混装组件', 'yoone-product-bundles'), $min_qty); ?></p>
|
|
||||||
<p class="yoone-bundle-selected"><?php esc_html_e('当前选择数量:', 'yoone-product-bundles'); ?><span class="yoone-bundle-selected-count">0</span></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (empty($allowed_products)) : ?>
|
|
||||||
<p><?php esc_html_e('暂无可混装商品,请在后台为该混装产品配置。', 'yoone-product-bundles'); ?></p>
|
|
||||||
<?php else : ?>
|
|
||||||
<?php foreach ($groups as $gid => $group) : ?>
|
|
||||||
<div class="yoone-bundle-group">
|
|
||||||
<?php if (!empty($group['term'])) : ?>
|
|
||||||
<h3 class="yoone-bundle-group-title"><?php echo esc_html($group['term']->name); ?></h3>
|
|
||||||
<?php else : ?>
|
|
||||||
<h3 class="yoone-bundle-group-title"><?php esc_html_e('可混装商品', 'yoone-product-bundles'); ?></h3>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (empty($group['items'])) : ?>
|
|
||||||
<p class="yoone-bundle-no-items"><?php esc_html_e('该分组暂无可混装商品', 'yoone-product-bundles'); ?></p>
|
|
||||||
<?php else : ?>
|
|
||||||
<div class="yoone-bundle-items-wrapper">
|
|
||||||
<?php foreach ($group['items'] as $item) : ?>
|
|
||||||
<?php
|
|
||||||
$pid = $item->get_id();
|
|
||||||
$thumbnail = $item->get_image('woocommerce_thumbnail');
|
|
||||||
?>
|
|
||||||
<div class="yoone-bundle-item-card">
|
|
||||||
<div class="item-image">
|
|
||||||
<a href="<?php echo esc_url(get_permalink($pid)); ?>" target="_blank">
|
|
||||||
<?php echo $thumbnail ? $thumbnail : wc_placeholder_img('woocommerce_thumbnail'); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<h4 class="item-title">
|
|
||||||
<a href="<?php echo esc_url(get_permalink($pid)); ?>" target="_blank"><?php echo esc_html($item->get_name()); ?></a>
|
|
||||||
</h4>
|
|
||||||
<div class="item-price">
|
|
||||||
<?php echo wp_kses_post($item->get_price_html()); ?>
|
|
||||||
</div>
|
|
||||||
<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" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="yoone-bundle-actions">
|
|
||||||
<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('加入购物车', 'yoone-product-bundles'); ?></button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
do_action('woocommerce_after_single_product_summary');
|
|
||||||
?>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php do_action('woocommerce_after_single_product'); ?>
|
|
||||||
|
|
||||||
<?php get_footer('shop'); ?>
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Plugin Name: Yoone Product Bundles (混装产品)
|
* Plugin Name: Yoone Product Bundles
|
||||||
* Description: 为 WooCommerce 提供“混装产品(Bundle)”类型:在后台为某个产品配置可混装的简单商品与最小混装数量;前端允许顾客按分类选择混装商品与数量,当达到最小数量时可加入购物车结算,价格为所选商品的价格总和。
|
* Description: Adds a "Mix and Match" product type to WooCommerce. Allows creating a bundle product where customers can select from a list of simple products, set quantities, and add to cart if a minimum quantity is met. The price is the sum of the selected products.
|
||||||
* Author: Yoone
|
* Author: Yoone
|
||||||
* Version: 0.1.0
|
* Version: 0.1.0
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
|
|
@ -12,15 +12,15 @@
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
// 简单防御:确保 WooCommerce 已激活
|
// Basic defense: ensure WooCommerce is active
|
||||||
if (! function_exists('WC')) {
|
if (! function_exists('WC')) {
|
||||||
add_action('admin_notices', function () {
|
add_action('admin_notices', function () {
|
||||||
echo '<div class="notice notice-error"><p>Yoone Product Bundles 需要启用 WooCommerce。</p></div>';
|
echo '<div class="notice notice-error"><p>' . esc_html__('Yoone Product Bundles requires WooCommerce to be activated.', 'yoone-product-bundles') . '</p></div>';
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 插件常量(路径)
|
// Plugin constants (paths)
|
||||||
define('YOONE_PB_PATH', plugin_dir_path(__FILE__));
|
define('YOONE_PB_PATH', plugin_dir_path(__FILE__));
|
||||||
define('YOONE_PB_URL', plugin_dir_url(__FILE__));
|
define('YOONE_PB_URL', plugin_dir_url(__FILE__));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue