462 lines
22 KiB
PHP
462 lines
22 KiB
PHP
<?php
|
||
/**
|
||
* 前端:为混装产品渲染自定义的“添加到购物车”表单,并处理验证和加入购物车的逻辑。
|
||
*/
|
||
defined('ABSPATH') || exit;
|
||
|
||
class Yoone_Product_Bundles_Frontend {
|
||
|
||
private static $_instance = null;
|
||
|
||
public static function instance() {
|
||
if (is_null(self::$_instance)) {
|
||
self::$_instance = new self();
|
||
}
|
||
return self::$_instance;
|
||
}
|
||
|
||
private function __construct() {
|
||
$this->setup_hooks();
|
||
}
|
||
|
||
/**
|
||
* 设置所有与前端相关的钩子。
|
||
*/
|
||
public function setup_hooks() {
|
||
// 处理混装产品的自定义“添加到购物车”流程
|
||
add_filter('woocommerce_add_to_cart_validation', array($this, 'process_bundle_add_to_cart'), 10, 3);
|
||
|
||
// 当混装容器被移除时,移除其子项目
|
||
add_action('woocommerce_cart_item_removed', array($this, 'remove_bundle_children'), 10, 2);
|
||
|
||
// 隐藏购物车中子项目的“移除”链接
|
||
add_filter('woocommerce_cart_item_remove_link', array($this, 'hide_child_remove_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,以便于样式化
|
||
add_filter('body_class', array($this, 'filter_body_class'));
|
||
|
||
// 统一覆盖 Woo 的价格 HTML:当订阅组件或其它部件尝试通过 get_price_html() 渲染价格时,避免显示 $0.00
|
||
add_filter('woocommerce_get_price_html', array($this, 'filter_yoone_bundle_price_html'), 10, 2);
|
||
|
||
// 根据当前购物车中的混装子项,动态添加“Bundle Discount”费用(负费用)
|
||
add_action('woocommerce_cart_calculate_fees', array($this, 'apply_bundle_discounts'), 20, 1);
|
||
|
||
// 在单品页尽早移除 APFS(All Products for Subscriptions)对按钮文案的有问题的过滤器,避免致命错误。
|
||
// 该插件在某些 Woo 版本上将 'woocommerce_product_single_add_to_cart_text' 过滤器声明为接收2个参数,
|
||
// 但实际只传递1个参数,导致 ArgumentCountError。
|
||
add_action('woocommerce_before_single_product', array($this, 'remove_conflicting_apfs_add_to_cart_text_filter'), 1);
|
||
|
||
// 隐藏默认价格,改为说明语;用我们的自定义表单替换默认的“添加到购物车”按钮
|
||
add_action('woocommerce_single_product_summary', array($this, 'remove_default_add_to_cart_for_bundle'), 29);
|
||
add_action('woocommerce_single_product_summary', array($this, 'replace_single_price_for_bundle'), 8);
|
||
add_action('woocommerce_single_product_summary', array($this, 'render_bundle_add_to_cart_form'), 30);
|
||
}
|
||
|
||
/**
|
||
* 劫持混装产品的“添加到购物车”流程。
|
||
* 验证提交,然后将容器和所有子产品添加到购物车。
|
||
*
|
||
* @return bool False 以阻止默认的“添加到购物车”操作。
|
||
*/
|
||
public function process_bundle_add_to_cart($passed, $product_id, $quantity) {
|
||
$product = wc_get_product($product_id);
|
||
if (!$product || $product->get_type() !== Yoone_Product_Bundles::TYPE) {
|
||
return $passed; // 不是我们的产品,直接通过。
|
||
}
|
||
|
||
// 1. 验证
|
||
$config = Yoone_Product_Bundles::get_bundle_config($product);
|
||
$min_qty = max(1, absint($config['min_qty']));
|
||
$config_edit_in_cart = isset($config['edit_in_cart']) && $config['edit_in_cart'] === 'yes';
|
||
// 前端表单可显式传递是否允许在购物车编辑;否则以产品配置为准
|
||
$posted_edit_in_cart = isset($_POST['yoone_bundle_edit_in_cart']) ? (bool) $_POST['yoone_bundle_edit_in_cart'] : null;
|
||
$edit_in_cart = is_null($posted_edit_in_cart) ? $config_edit_in_cart : (bool) $posted_edit_in_cart;
|
||
$components = !empty($_POST['yoone_bundle_components']) ? (array) $_POST['yoone_bundle_components'] : array();
|
||
|
||
$total_qty = 0;
|
||
$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. 添加商品到购物车
|
||
try {
|
||
$bundle_container_id = uniqid('bundle_');
|
||
|
||
// 如果 APFS 可用,从请求中读取选中的订阅方案,并在容器及子项目上附加 wcsatt_data。
|
||
// 注意:部分环境可能存在类名,但方法签名不同;为防止致命错误,需检测方法是否存在。
|
||
$wcsatt_data = array();
|
||
if (class_exists('WCS_ATT_Product_Schemes')) {
|
||
$posted_scheme_key = null;
|
||
if (method_exists('WCS_ATT_Product_Schemes', 'get_posted_subscription_scheme')) {
|
||
// APFS 正常方法:根据当前产品读取提交的订阅方案键。
|
||
$posted_scheme_key = WCS_ATT_Product_Schemes::get_posted_subscription_scheme($product_id);
|
||
} else {
|
||
// 兼容性回退:尝试从请求参数中获取。
|
||
// APFS 默认使用 name=wcsatt_subscription_scheme 的字段提交所选方案;此处作为保护性回退。
|
||
$posted_scheme_key = isset($_REQUEST['wcsatt_subscription_scheme']) ? wc_clean(wp_unslash($_REQUEST['wcsatt_subscription_scheme'])) : null;
|
||
// 若无法识别,则保持为 null/false,表示一次性购买。
|
||
}
|
||
// 允许显式地传递 false,表示一次性购买。APFS 会在后续根据该键处理。
|
||
$wcsatt_data = array('wcsatt_data' => array('active_subscription_scheme' => $posted_scheme_key));
|
||
}
|
||
|
||
// 添加主混装产品(作为价格为0的容器)
|
||
WC()->cart->add_to_cart($product_id, 1, 0, array(), array_merge($wcsatt_data, array(
|
||
'yoone_bundle_container_id' => $bundle_container_id,
|
||
'yoone_bundle_edit_in_cart' => $edit_in_cart ? 'yes' : 'no',
|
||
)));
|
||
|
||
// 添加子组件
|
||
foreach ($clean_components as $comp_id => $qty) {
|
||
WC()->cart->add_to_cart($comp_id, $qty, 0, array(), array_merge($wcsatt_data, array(
|
||
'yoone_bundle_parent_id' => $bundle_container_id,
|
||
'yoone_bundle_parent_product_id' => $product_id,
|
||
'yoone_bundle_edit_in_cart' => $edit_in_cart ? 'yes' : 'no',
|
||
)));
|
||
}
|
||
|
||
// 设置成功消息并重定向
|
||
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;
|
||
}
|
||
|
||
// 阻止主产品的默认“添加到购物车”行为
|
||
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) {
|
||
// 忽略错误,尽可能不中断页面。
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 当一个购物车项目被移除时,检查它是否是一个混装容器。
|
||
* 如果是,则找到并移除其所有的子项目。
|
||
*/
|
||
public function remove_bundle_children($removed_cart_item_key, $cart) {
|
||
// 此钩子在项目已从购物车会话中移除后触发,所以我们在 `removed_cart_contents` 中查找它。
|
||
if (!isset($cart->removed_cart_contents[$removed_cart_item_key])) {
|
||
return;
|
||
}
|
||
|
||
$removed_item = $cart->removed_cart_contents[$removed_cart_item_key];
|
||
|
||
// 检查被移除的商品是否是我们的混装容器。
|
||
if (isset($removed_item['yoone_bundle_container_id'])) {
|
||
$bundle_container_id = $removed_item['yoone_bundle_container_id'];
|
||
$child_item_keys_to_remove = [];
|
||
|
||
// 遍历购物车,找到所有属于此容器的子项目。
|
||
// 我们不能在遍历时直接修改购物车,所以我们先收集要移除的项目的key。
|
||
foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
|
||
if (isset($cart_item['yoone_bundle_parent_id']) && $cart_item['yoone_bundle_parent_id'] === $bundle_container_id) {
|
||
$child_item_keys_to_remove[] = $cart_item_key;
|
||
}
|
||
}
|
||
|
||
// 现在,遍历收集到的key并从购物车中移除子项目。
|
||
foreach ($child_item_keys_to_remove as $key_to_remove) {
|
||
$cart->remove_cart_item($key_to_remove);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 隐藏混装产品子项目的移除链接。
|
||
*/
|
||
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'])) {
|
||
// 当允许在购物车编辑时,不隐藏移除链接
|
||
$editable = isset($cart_item['yoone_bundle_edit_in_cart']) && $cart_item['yoone_bundle_edit_in_cart'] === 'yes';
|
||
if (! $editable) {
|
||
return '';
|
||
}
|
||
}
|
||
return $link;
|
||
}
|
||
|
||
/**
|
||
* 对于子项目,在购物车中添加一个元信息行,以链接回父混装产品。
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 为购物车行添加样式类,便于将子项目缩小并缩进显示。
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 仅为混装产品移除默认的“添加到购物车”按钮。
|
||
*/
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 在单品页隐藏默认价格输出,并显示“价格由所选子商品决定”的说明。
|
||
*/
|
||
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>';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 为混装产品渲染自定义的“添加到购物车”表单。
|
||
*/
|
||
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/');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 为混装产品页面添加一个 body class。
|
||
*/
|
||
public function filter_body_class($classes) {
|
||
global $post;
|
||
if (is_singular('product') && $post) {
|
||
$product = wc_get_product($post->ID);
|
||
if ($product && $product->get_type() === Yoone_Product_Bundles::TYPE) {
|
||
$classes[] = 'yoone-bundle-product';
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
} |