| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- <?php
- if (!defined('WPINC')) {
- die;
- }
- class Studiou_WC_FPP_Pricing {
- const META_KEY = '_studiou_fpp_qty_tiers';
- public function __construct() {
- // Admin: render tier editor inside each variation panel
- add_action('woocommerce_variation_options_pricing', array($this, 'render_variation_tier_editor'), 20, 3);
- // Admin: save tiers per variation
- add_action('woocommerce_save_product_variation', array($this, 'save_variation_tiers'), 10, 2);
- // Frontend: expose tiers on the variations data emitted for the variations form
- add_filter('woocommerce_available_variation', array($this, 'inject_variation_tier_data'), 10, 3);
- // Frontend: render placeholder container on the single product page
- add_action('woocommerce_single_product_summary', array($this, 'render_tier_display_placeholder'), 34);
- // Frontend: render pre-selection notice — uses summary hook so it works with any
- // add-to-cart UI (WC native variations form, pvtfw/product-variant-table, etc.)
- add_action('woocommerce_single_product_summary', array($this, 'render_preform_notice'), 25);
- // Frontend: emit tier data as an inline JSON blob so JS can match rows of custom
- // variant-table plugins to their tier configuration.
- add_action('wp_footer', array($this, 'emit_tier_data_script'));
- // Cart runtime: apply discounted unit price based on line quantity
- add_action('woocommerce_before_calculate_totals', array($this, 'apply_cart_discount'), 20, 1);
- }
- // ============================================================
- // Data access
- // ============================================================
- public static function get_tiers($variation_id) {
- $raw = get_post_meta((int) $variation_id, self::META_KEY, true);
- if (empty($raw) || !is_array($raw)) {
- return array();
- }
- return self::normalize_tiers($raw);
- }
- private static function normalize_tiers(array $tiers) {
- $out = array();
- foreach ($tiers as $row) {
- if (!is_array($row)) {
- continue;
- }
- $from = isset($row['from_qty']) ? (int) $row['from_qty'] : 0;
- if ($from < 1) {
- continue;
- }
- $pct = isset($row['percent']) ? (float) $row['percent'] : 0.0;
- $pct = max(0.0, min(100.0, $pct));
- $out[$from] = array('from_qty' => $from, 'percent' => $pct);
- }
- ksort($out);
- return array_values($out);
- }
- public static function resolve_tier(array $tiers, $qty) {
- $qty = (int) $qty;
- if ($qty < 1 || empty($tiers)) {
- return null;
- }
- $match = null;
- foreach ($tiers as $tier) {
- if ($tier['from_qty'] <= $qty) {
- $match = $tier;
- } else {
- break;
- }
- }
- return $match;
- }
- public static function compute_discounted_price($base_price, array $tiers, $qty) {
- $base = (float) $base_price;
- if ($base <= 0.0 || empty($tiers)) {
- return $base;
- }
- $tier = self::resolve_tier($tiers, $qty);
- if (!$tier || $tier['percent'] <= 0.0) {
- return $base;
- }
- $discounted = $base * (1.0 - ($tier['percent'] / 100.0));
- return round($discounted, wc_get_price_decimals());
- }
- // ============================================================
- // Admin UI: variation editor
- // ============================================================
- public function render_variation_tier_editor($loop, $variation_data, $variation) {
- $parent_id = (int) $variation->post_parent;
- if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
- return;
- }
- $tiers = self::get_tiers($variation->ID);
- ?>
- <div class="studiou-fpp-qty-tiers-field form-row form-row-full" data-loop="<?php echo esc_attr($loop); ?>">
- <label>
- <?php esc_html_e('Quantity Discount Tiers', 'studiou-wc-free-photo-product'); ?>
- <?php echo wc_help_tip(__('Define quantity thresholds and percentage discounts. The tier with the largest "From Qty" that is less than or equal to the cart quantity is applied to the entire line. Leave empty to use the explicit variation price.', 'studiou-wc-free-photo-product')); ?>
- </label>
- <table class="studiou-fpp-qty-tiers-table widefat">
- <thead>
- <tr>
- <th class="studiou-fpp-qty-col-from"><?php esc_html_e('From Qty', 'studiou-wc-free-photo-product'); ?></th>
- <th class="studiou-fpp-qty-col-pct"><?php esc_html_e('Discount %', 'studiou-wc-free-photo-product'); ?></th>
- <th class="studiou-fpp-qty-col-remove"></th>
- </tr>
- </thead>
- <tbody>
- <?php foreach ($tiers as $idx => $tier) : ?>
- <tr class="studiou-fpp-qty-tier-row">
- <td>
- <input type="number" min="1" step="1"
- name="studiou_fpp_qty_tiers[<?php echo esc_attr($loop); ?>][<?php echo esc_attr($idx); ?>][from_qty]"
- value="<?php echo esc_attr($tier['from_qty']); ?>" />
- </td>
- <td>
- <input type="number" min="0" max="100" step="0.01"
- name="studiou_fpp_qty_tiers[<?php echo esc_attr($loop); ?>][<?php echo esc_attr($idx); ?>][percent]"
- value="<?php echo esc_attr($tier['percent']); ?>" />
- </td>
- <td>
- <button type="button" class="button studiou-fpp-qty-tier-remove"
- title="<?php esc_attr_e('Remove row', 'studiou-wc-free-photo-product'); ?>">×</button>
- </td>
- </tr>
- <?php endforeach; ?>
- </tbody>
- </table>
- <p>
- <button type="button" class="button studiou-fpp-qty-tier-add" data-loop="<?php echo esc_attr($loop); ?>">
- <?php esc_html_e('+ Add tier', 'studiou-wc-free-photo-product'); ?>
- </button>
- </p>
- </div>
- <?php
- }
- public function save_variation_tiers($variation_id, $i) {
- $parent_id = wp_get_post_parent_id($variation_id);
- if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
- delete_post_meta($variation_id, self::META_KEY);
- return;
- }
- $raw = array();
- if (isset($_POST['studiou_fpp_qty_tiers'][$i]) && is_array($_POST['studiou_fpp_qty_tiers'][$i])) {
- $raw = wp_unslash($_POST['studiou_fpp_qty_tiers'][$i]);
- }
- $sanitized = array();
- foreach ($raw as $row) {
- if (!is_array($row)) {
- continue;
- }
- $from = isset($row['from_qty']) ? absint($row['from_qty']) : 0;
- if ($from < 1) {
- continue;
- }
- $pct = isset($row['percent']) ? (float) $row['percent'] : 0.0;
- $pct = max(0.0, min(100.0, $pct));
- $sanitized[] = array('from_qty' => $from, 'percent' => $pct);
- }
- $sanitized = self::normalize_tiers($sanitized);
- if (empty($sanitized)) {
- delete_post_meta($variation_id, self::META_KEY);
- } else {
- update_post_meta($variation_id, self::META_KEY, $sanitized);
- }
- }
- // ============================================================
- // Frontend: expose tiers on variation data + placeholder container
- // ============================================================
- public function inject_variation_tier_data($variation_data, $product, $variation) {
- $parent_id = $variation->get_parent_id();
- if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
- return $variation_data;
- }
- $tiers = self::get_tiers($variation->get_id());
- $base_price = get_post_meta($variation->get_id(), '_price', true);
- $variation_data['studiou_fpp_qty_tiers'] = $tiers;
- $variation_data['studiou_fpp_base_price'] = ($base_price !== '' && $base_price !== false) ? (float) $base_price : 0.0;
- return $variation_data;
- }
- public function render_tier_display_placeholder() {
- global $product;
- if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
- return;
- }
- echo '<div class="studiou-fpp-qty-tiers-display" style="display:none;"></div>';
- }
- /**
- * Render a persistent notice above the variations form summarising tier availability.
- * Only shown if at least one variation of the current product has tiers configured.
- */
- public function render_preform_notice() {
- global $product;
- if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
- return;
- }
- if (!$product->is_type('variable')) {
- return;
- }
- $has_tiers = false;
- $max_discount = 0.0;
- foreach ($product->get_children() as $variation_id) {
- $tiers = self::get_tiers($variation_id);
- if (!empty($tiers)) {
- $has_tiers = true;
- foreach ($tiers as $tier) {
- if ($tier['percent'] > $max_discount) {
- $max_discount = $tier['percent'];
- }
- }
- }
- }
- if (!$has_tiers) {
- return;
- }
- $label = ($max_discount > 0)
- ? sprintf(
- /* translators: %s: maximum discount percentage */
- __('Quantity discount available — save up to %s%%. Select a variant to see the full tier table.', 'studiou-wc-free-photo-product'),
- rtrim(rtrim(number_format($max_discount, 2, '.', ''), '0'), '.')
- )
- : __('Quantity discount available — select a variant to see the full tier table.', 'studiou-wc-free-photo-product');
- ?>
- <div class="studiou-fpp-qty-tiers-notice">
- <span class="dashicons dashicons-tag"></span>
- <span class="studiou-fpp-qty-tiers-notice-text"><?php echo esc_html($label); ?></span>
- </div>
- <?php
- }
- /**
- * Emit tier data for all variations of the current FPP product as an inline JSON
- * object keyed by variation ID. Consumed by frontend JS to wire up custom variant
- * table plugins (pvtfw) where the native variations form is absent.
- */
- public function emit_tier_data_script() {
- if (!is_product()) {
- return;
- }
- global $product;
- if (!$product || !is_a($product, 'WC_Product')) {
- $product = wc_get_product(get_queried_object_id());
- }
- if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
- return;
- }
- if (!$product->is_type('variable')) {
- return;
- }
- $data = array();
- foreach ($product->get_children() as $variation_id) {
- $tiers = self::get_tiers($variation_id);
- if (empty($tiers)) {
- continue;
- }
- $base_price = get_post_meta($variation_id, '_price', true);
- $data[(string) $variation_id] = array(
- 'tiers' => $tiers,
- 'base_price' => ($base_price !== '' && $base_price !== false) ? (float) $base_price : 0.0,
- );
- }
- if (empty($data)) {
- return;
- }
- echo '<script>window.studiouFppTierData = ' . wp_json_encode($data) . ';</script>';
- }
- // ============================================================
- // Cart runtime
- // ============================================================
- public function apply_cart_discount($cart) {
- if (is_admin() && !defined('DOING_AJAX')) {
- return;
- }
- if (!$cart || !is_a($cart, 'WC_Cart')) {
- return;
- }
- foreach ($cart->get_cart() as $cart_item) {
- if (empty($cart_item['variation_id'])) {
- continue;
- }
- $product = isset($cart_item['data']) ? $cart_item['data'] : null;
- if (!$product) {
- continue;
- }
- $parent_id = $product->get_parent_id();
- if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
- continue;
- }
- $variation_id = (int) $cart_item['variation_id'];
- $tiers = self::get_tiers($variation_id);
- if (empty($tiers)) {
- continue;
- }
- // Read base price from meta to avoid compounding if the hook fires twice per request
- $base_price = get_post_meta($variation_id, '_price', true);
- if ($base_price === '' || $base_price === false) {
- continue;
- }
- $qty = (int) $cart_item['quantity'];
- $discounted = self::compute_discounted_price((float) $base_price, $tiers, $qty);
- if ($discounted > 0.0) {
- $product->set_price($discounted);
- }
- }
- }
- }
|