Plugin Directory

Changeset 3741869


Ignore:
Timestamp:
10/12/2026 02:32:26 PM (4 weeks ago)
Author:
serdarkeskin
Message:

Release 1.2.0

Location:
giftvo
Files:
4 edited
1 copied

Legend:

Unmodified
Added
Removed
  • giftvo/tags/1.2.0/giftvo.php

    r3706274 r3741869  
    22/**
    33 * Plugin Name: Giftvo - Tiered Gifts for WooCommerce
    4  * Description: Automatically adds free gift product(s) to the cart when the cart total reaches a threshold you configure. Unlimited tiers, "let the customer pick" gifts, colour/variation choice, a live progress bar with an optional free-shipping goal, and a real 0.00 line item that is safe for every payment gateway.
    5  * Version: 1.1.0
     4 * Description: Automatically adds free gift product(s) to the cart when the cart total reaches a threshold you configure. Unlimited tiers, "let the customer pick" gifts, colour/variation choice, a live progress bar with optional free-shipping and discount milestones, and a real 0.00 line item that is safe for every payment gateway.
     5 * Version: 1.2.0
    66 * Author: Serdar Keskin
    77 * Author URI: https://zaundiy.de
    … …  
    2626        const FLAG     = 'wctfg_gift';       // cart item marker (= threshold value)
    2727        const EPS      = 0.00001;
     28        const VERSION  = '1.2.0';
     29        // Milestones closer than this share of the bar's top amount are drawn as one marker.
     30        const NEAR     = 0.03;
    2831
    2932        private static $settings_cache = null;
    3033
    3134        public function __construct() {
    32                 // i18n — bundled /languages translations (loads on init per WP 6.7+ guidance).
    33                 // A site-wide override in wp-content/languages/plugins/ still takes precedence.
    34                 add_action('init', [$this, 'load_textdomain']);
    35 
    3635                // Admin
    3736                add_action('admin_menu',                 [$this, 'admin_menu']);
    … …  
    6059                add_action('woocommerce_before_cart',                       [$this, 'progress_on_cart']);
    6160                add_action('woocommerce_widget_shopping_cart_before_buttons', [$this, 'progress_on_minicart']);
    62         }
    63 
    64         public function load_textdomain() {
    65                 load_plugin_textdomain('giftvo', false, dirname(plugin_basename(__FILE__)) . '/languages');
    6661        }
    6762
    … …  
    8580                        'free_ship_amount' => '',
    8681                        'discount_enable'  => 0,
    87                         'discount_amount'  => '',
    88                         'discount_percent' => '',
     82                        'discount_tiers'   => [],        // [['amount' => float, 'label' => string], …]
     83                        'discount_amount'  => '',        // legacy (≤ 1.1.0): single discount milestone
     84                        'discount_percent' => '',        // legacy (≤ 1.1.0)
     85                        'bar_scale'        => 'linear',  // linear|steps
    8986                ];
    9087                $s = get_option(self::SETTINGS, []);
    91                 self::$settings_cache = array_merge($defaults, is_array($s) ? $s : []);
     88                $s = array_merge($defaults, is_array($s) ? $s : []);
     89
     90                // Upgrade path: a single 1.1.0 discount milestone becomes the first row of the list.
     91                $s['discount_tiers'] = self::clean_discount_tiers($s['discount_tiers']);
     92                if (!$s['discount_tiers'] && (float) $s['discount_amount'] > 0) {
     93                        $s['discount_tiers'] = self::clean_discount_tiers([['amount' => $s['discount_amount'], 'label' => $s['discount_percent']]]);
     94                }
     95                $s['bar_scale'] = ($s['bar_scale'] === 'steps') ? 'steps' : 'linear';
     96
     97                self::$settings_cache = $s;
    9298                return self::$settings_cache;
     99        }
     100
     101        /** Sanitise discount milestones: [['amount' => float > 0, 'label' => string], …] sorted by amount. */
     102        private static function clean_discount_tiers($rows) {
     103                $out = [];
     104                foreach ((array) $rows as $r) {
     105                        if (!is_array($r)) continue;
     106                        $amount = isset($r['amount']) ? (float) str_replace(',', '.', (string) $r['amount']) : 0;
     107                        if ($amount <= 0) continue;
     108                        $out[] = ['amount' => $amount, 'label' => isset($r['label']) ? sanitize_text_field((string) $r['label']) : ''];
     109                }
     110                usort($out, function ($a, $b) { return $a['amount'] <=> $b['amount']; });
     111                return $out;
    93112        }
    94113
    … …  
    162181                wp_enqueue_style('select2');
    163182
    164                 wp_register_script('wctfg-admin', '', ['jquery', 'wc-enhanced-select'], '1.1.0', true);
     183                wp_register_script('wctfg-admin', '', ['jquery', 'wc-enhanced-select'], self::VERSION, true);
    165184                wp_enqueue_script('wctfg-admin');
    166185                wp_localize_script('wctfg-admin', 'WctfgAdmin', [
    … …  
    217236                        if ($('.wctfg-row').length > 1) $(this).closest('tr').remove();
    218237                        else { $(this).closest('tr').find('input').val(''); $(this).closest('tr').find('select.wc-product-search').val(null).trigger('change'); }
     238                });
     239                $('#wctfg-disc-add').on('click', function(){
     240                        var $row = $('#wctfg-disc-table .wctfg-disc-row:first').clone();
     241                        $row.find('input').val('');
     242                        $('#wctfg-disc-table tbody').append($row);
     243                });
     244                $('#wctfg-disc-table').on('click', '.wctfg-disc-del', function(){
     245                        if ($('#wctfg-disc-table .wctfg-disc-row').length > 1) $(this).closest('tr').remove();
     246                        else $(this).closest('tr').find('input').val('');
    219247                });
    220248        });
    … …  
    338366                                        </tr>
    339367                                        <tr>
    340                                                 <th scope="row"><?php echo esc_html__('Discount amount', 'giftvo'); ?></th>
     368                                                <th scope="row"><?php echo esc_html__('Discount milestones', 'giftvo'); ?></th>
    341369                                                <td>
    342                                                         <input type="number" step="0.01" min="0" name="discount_amount" value="<?php echo esc_attr($s['discount_amount']); ?>" style="width:150px">
    343                                                         <p class="description"><?php echo esc_html__('Cart amount that unlocks the discount. Uses the same threshold basis as gifts.', 'giftvo'); ?></p>
     370                                                        <?php $disc_rows = $s['discount_tiers'] ? $s['discount_tiers'] : [['amount' => '', 'label' => '']]; ?>
     371                                                        <table class="widefat striped" id="wctfg-disc-table" style="max-width:460px">
     372                                                                <thead>
     373                                                                        <tr>
     374                                                                                <th><?php echo esc_html__('Cart amount', 'giftvo'); ?></th>
     375                                                                                <th><?php echo esc_html__('Label', 'giftvo'); ?></th>
     376                                                                                <th style="width:50px"></th>
     377                                                                        </tr>
     378                                                                </thead>
     379                                                                <tbody>
     380                                                                        <?php foreach ($disc_rows as $d): ?>
     381                                                                        <tr class="wctfg-disc-row">
     382                                                                                <td><input type="number" step="0.01" min="0" name="disc_amount[]" value="<?php echo esc_attr($d['amount']); ?>" style="width:150px"></td>
     383                                                                                <td><input type="text" name="disc_label[]" value="<?php echo esc_attr($d['label']); ?>" placeholder="5%" style="width:120px"></td>
     384                                                                                <td><button type="button" class="button wctfg-disc-del" title="<?php echo esc_attr__('Remove row', 'giftvo'); ?>">✕</button></td>
     385                                                                        </tr>
     386                                                                        <?php endforeach; ?>
     387                                                                </tbody>
     388                                                        </table>
     389                                                        <p><button type="button" class="button" id="wctfg-disc-add">+ <?php echo esc_html__('Add discount milestone', 'giftvo'); ?></button></p>
     390                                                        <p class="description" style="max-width:640px"><?php echo esc_html__('One marker per row, e.g. 1000 → “5%”, 2000 → “6%”. The label is shown on the marker and in the message. Uses the same threshold basis as gifts. Giftvo only displays these milestones — set up the discount itself in your pricing or discount plugin.', 'giftvo'); ?></p>
    344391                                                </td>
    345392                                        </tr>
    346393                                        <tr>
    347                                                 <th scope="row"><?php echo esc_html__('Discount label', 'giftvo'); ?></th>
     394                                                <th scope="row"><?php echo esc_html__('Progress bar scale', 'giftvo'); ?></th>
    348395                                                <td>
    349                                                         <input type="text" name="discount_percent" value="<?php echo esc_attr($s['discount_percent']); ?>" placeholder="5%" style="width:120px">
    350                                                         <p class="description"><?php echo esc_html__('Shown on the marker and in the message, e.g. “5%”.', 'giftvo'); ?></p>
     396                                                        <label><input type="radio" name="bar_scale" value="linear" <?php checked($s['bar_scale'], 'linear'); ?>> <?php echo esc_html__('Proportional — each marker sits at its amount', 'giftvo'); ?></label><br>
     397                                                        <label><input type="radio" name="bar_scale" value="steps" <?php checked($s['bar_scale'], 'steps'); ?>> <?php echo esc_html__('Evenly spaced — every milestone gets the same room (best for many milestones)', 'giftvo'); ?></label>
    351398                                                </td>
    352399                                        </tr>
    … …  
    414461                usort($tiers, function ($a, $b) { return $a['threshold'] <=> $b['threshold']; });
    415462                update_option(self::OPTION, $tiers, false);
     463
     464                // Discount milestones: two parallel lists, one entry per table row.
     465                $d_amounts = isset($_POST['disc_amount']) ? array_map('sanitize_text_field', wp_unslash((array) $_POST['disc_amount'])) : [];
     466                $d_labels  = isset($_POST['disc_label'])  ? array_values(array_map('sanitize_text_field', wp_unslash((array) $_POST['disc_label']))) : [];
     467                $disc      = [];
     468                foreach (array_values($d_amounts) as $i => $amount) {
     469                        $disc[] = ['amount' => $amount, 'label' => $d_labels[$i] ?? ''];
     470                }
    416471
    417472                // Settings
    … …  
    434489                        'free_ship_amount' => isset($_POST['free_ship_amount']) ? max(0, (float) str_replace(',', '.', sanitize_text_field(wp_unslash($_POST['free_ship_amount'])))) : 0,
    435490                        'discount_enable'  => !empty($_POST['discount_enable']) ? 1 : 0,
    436                         'discount_amount'  => isset($_POST['discount_amount']) ? max(0, (float) str_replace(',', '.', sanitize_text_field(wp_unslash($_POST['discount_amount'])))) : 0,
    437                         'discount_percent' => isset($_POST['discount_percent']) ? sanitize_text_field(wp_unslash($_POST['discount_percent'])) : '',
     491                        'discount_tiers'   => self::clean_discount_tiers($disc),
     492                        'discount_amount'  => '', // legacy single milestone now lives in discount_tiers
     493                        'discount_percent' => '',
     494                        'bar_scale'        => (isset($_POST['bar_scale']) && sanitize_key(wp_unslash($_POST['bar_scale'])) === 'steps') ? 'steps' : 'linear',
    438495                ];
    439496                update_option(self::SETTINGS, $settings, false);
    … …  
    473530                        // Re-run through the same whitelist as save.
    474531                        $in = $data['settings'];
     532                        // Accept the 1.2.0 list as well as a 1.1.0 export with a single discount milestone.
     533                        $disc = self::clean_discount_tiers($in['discount_tiers'] ?? []);
     534                        if (!$disc && isset($in['discount_amount']) && (float) str_replace(',', '.', (string) $in['discount_amount']) > 0) {
     535                                $disc = self::clean_discount_tiers([['amount' => $in['discount_amount'], 'label' => $in['discount_percent'] ?? '']]);
     536                        }
    475537                        $accent = isset($in['accent']) ? trim((string) $in['accent']) : '';
    476538                        update_option(self::SETTINGS, [
    … …  
    489551                                'free_ship_amount' => isset($in['free_ship_amount']) ? max(0, (float) str_replace(',', '.', (string) $in['free_ship_amount'])) : 0,
    490552                                'discount_enable'  => !empty($in['discount_enable']) ? 1 : 0,
    491                                 'discount_amount'  => isset($in['discount_amount']) ? max(0, (float) str_replace(',', '.', (string) $in['discount_amount'])) : 0,
    492                                 'discount_percent' => isset($in['discount_percent']) ? sanitize_text_field((string) $in['discount_percent']) : '',
     553                                'discount_tiers'   => $disc,
     554                                'discount_amount'  => '',
     555                                'discount_percent' => '',
     556                                'bar_scale'        => (($in['bar_scale'] ?? '') === 'steps') ? 'steps' : 'linear',
    493557                        ], false);
    494558                }
    … …  
    10201084                $accent = $this->accent();
    10211085
    1022                 // Two independent, separately-toggled milestone sources feed one bar:
    1023                 // gift tiers (🎁, gated by "progress_bar") and a free-shipping goal (🚚,
    1024                 // gated by "free_ship_enable"). Either source alone renders the bar.
     1086                // Three separately-toggled milestone sources feed one bar: gift tiers (🎁,
     1087                // gated by "progress_bar"), a free-shipping goal (🚚, "free_ship_enable") and
     1088                // any number of discount milestones (%, "discount_enable"). Any one alone
     1089                // renders the bar.
    10251090                $show_gifts = !empty($s['progress_bar']);
    10261091                $ship_amt   = (float) $s['free_ship_amount'];
    10271092                $show_ship  = !empty($s['free_ship_enable']) && $ship_amt > 0;
    1028                 $disc_amt   = (float) $s['discount_amount'];
    1029                 $disc_txt   = trim((string) $s['discount_percent']);
    1030                 if ($disc_txt === '') { $disc_txt = '%'; }
    1031                 $show_disc  = !empty($s['discount_enable']) && $disc_amt > 0;
     1093                $show_disc  = !empty($s['discount_enable']) && !empty($s['discount_tiers']);
    10321094
    10331095                $milestones = [];
    10341096                if ($show_gifts) {
    10351097                        foreach ($this->normalized_tiers() as $t) {
    1036                                 $milestones[] = ['threshold' => (float) $t['threshold'], 'type' => 'gift'];
     1098                                $milestones[] = ['threshold' => (float) $t['threshold'], 'type' => 'gift', 'label' => ''];
    10371099                        }
    10381100                }
    10391101                if ($show_ship) {
    1040                         $milestones[] = ['threshold' => $ship_amt, 'type' => 'ship'];
     1102                        $milestones[] = ['threshold' => $ship_amt, 'type' => 'ship', 'label' => ''];
    10411103                }
    10421104                if ($show_disc) {
    1043                         $milestones[] = ['threshold' => $disc_amt, 'type' => 'disc'];
     1105                        foreach ($s['discount_tiers'] as $d) {
     1106                                $milestones[] = ['threshold' => (float) $d['amount'], 'type' => 'disc', 'label' => ($d['label'] !== '' ? $d['label'] : '%')];
     1107                        }
    10441108                }
    10451109                if (empty($milestones)) return '';
    … …  
    10471111
    10481112                $subtotal = $this->base_subtotal(WC()->cart);
    1049 
    1050                 $max = 0.0;
    1051                 foreach ($milestones as $m) $max = max($max, $m['threshold']);
    1052                 $pct = $max > 0 ? min(100, ($subtotal / $max) * 100) : 0;
    1053 
    1054                 // Next unreached milestone (smallest threshold still above the subtotal).
     1113                $max      = (float) $milestones[count($milestones) - 1]['threshold'];
     1114
     1115                // Group milestones that are (nearly) at the same amount so their icons never
     1116                // overlap, e.g. free shipping at 999 and a discount at 1000. A group is drawn
     1117                // at its highest amount, so a marker never promises a reward too early.
     1118                $groups = [];
     1119                foreach ($milestones as $m) {
     1120                        $last = count($groups) - 1;
     1121                        if ($last >= 0 && ($m['threshold'] - $groups[$last]['min']) <= $max * self::NEAR) {
     1122                                $groups[$last]['items'][] = $m;
     1123                                $groups[$last]['max']     = $m['threshold'];
     1124                        } else {
     1125                                $groups[] = ['min' => $m['threshold'], 'max' => $m['threshold'], 'items' => [$m]];
     1126                        }
     1127                }
     1128                $n     = count($groups);
     1129                $steps = ($s['bar_scale'] === 'steps');
     1130
     1131                // Marker position and bar fill. "linear": proportional to the amount.
     1132                // "steps": every group gets the same room; the fill moves proportionally
     1133                // inside the step the customer is currently in.
     1134                foreach ($groups as $i => $g) {
     1135                        $groups[$i]['left'] = $steps ? (($i + 1) / $n) * 100 : ($max > 0 ? ($g['max'] / $max) * 100 : 0);
     1136                }
     1137                if ($steps) {
     1138                        $pct = 100;
     1139                        $prev = 0.0;
     1140                        foreach ($groups as $i => $g) {
     1141                                if ($subtotal + self::EPS < $g['max']) {
     1142                                        $span = max(self::EPS, $g['max'] - $prev);
     1143                                        $pct  = (($i + max(0, min(1, ($subtotal - $prev) / $span))) / $n) * 100;
     1144                                        break;
     1145                                }
     1146                                $prev = $g['max'];
     1147                        }
     1148                } else {
     1149                        $pct = $max > 0 ? min(100, ($subtotal / $max) * 100) : 0;
     1150                }
     1151
     1152                // Message: the next group not yet fully reached and everything it still unlocks.
    10551153                $next = null;
    1056                 foreach ($milestones as $m) { if ($m['threshold'] > $subtotal + self::EPS) { $next = $m; break; } }
     1154                foreach ($groups as $g) { if ($g['max'] > $subtotal + self::EPS) { $next = $g; break; } }
    10571155
    10581156                if ($next) {
    1059                         $remaining   = $next['threshold'] - $subtotal;
     1157                        $remaining   = $next['max'] - $subtotal;
    10601158                        $amount_html = '<b style="color:' . esc_attr($accent) . '">' . wp_kses_post(wc_price($remaining)) . '</b>';
    1061                         $next_gift = false; $next_ship = false; $next_disc = false;
    1062                         foreach ($milestones as $mm) {
    1063                                 if (abs($mm['threshold'] - $next['threshold']) < self::EPS) {
    1064                                         if ($mm['type'] === 'ship') { $next_ship = true; }
    1065                                         elseif ($mm['type'] === 'disc') { $next_disc = true; }
    1066                                         else { $next_gift = true; }
    1067                                 }
    1068                         }
    1069                         if ($next_gift && $next_ship) {
     1159                        $next_gift = false; $next_ship = false; $disc_txt = '';
     1160                        foreach ($next['items'] as $mm) {
     1161                                if ($mm['threshold'] <= $subtotal + self::EPS) continue; // already unlocked
     1162                                if ($mm['type'] === 'ship') { $next_ship = true; }
     1163                                elseif ($mm['type'] === 'disc') { $disc_txt = $mm['label']; } // highest discount in the group wins
     1164                                else { $next_gift = true; }
     1165                        }
     1166                        $disc_html = '<b style="color:' . esc_attr($accent) . '">' . esc_html($disc_txt) . '</b>';
     1167                        if ($next_gift && $next_ship && $disc_txt !== '') {
     1168                                /* translators: %1$s = formatted price amount, %2$s = discount label such as "5%" */
     1169                                $msg = sprintf(__('Spend %1$s more for a free gift, free shipping + %2$s off!', 'giftvo'), $amount_html, $disc_html);
     1170                        } elseif ($next_gift && $next_ship) {
    10701171                                /* translators: %s = formatted price amount */
    10711172                                $msg = sprintf(__('Spend %s more for a free gift + free shipping!', 'giftvo'), $amount_html);
     1173                        } elseif ($next_gift && $disc_txt !== '') {
     1174                                /* translators: %1$s = formatted price amount, %2$s = discount label such as "5%" */
     1175                                $msg = sprintf(__('Spend %1$s more for a free gift + %2$s off!', 'giftvo'), $amount_html, $disc_html);
     1176                        } elseif ($next_ship && $disc_txt !== '') {
     1177                                /* translators: %1$s = formatted price amount, %2$s = discount label such as "5%" */
     1178                                $msg = sprintf(__('Spend %1$s more for free shipping + %2$s off!', 'giftvo'), $amount_html, $disc_html);
    10721179                        } elseif ($next_ship) {
    10731180                                /* translators: %s = formatted price amount */
    10741181                                $msg = sprintf(__('Spend %s more for free shipping!', 'giftvo'), $amount_html);
    1075                         } elseif ($next_disc && !$next_gift) {
     1182                        } elseif ($disc_txt !== '') {
    10761183                                /* translators: %1$s = amount, %2$s = discount label such as "5%" */
    1077                                 $msg = sprintf(__('Spend %1$s more for %2$s off!', 'giftvo'), $amount_html, '<b style="color:' . esc_attr($accent) . '">' . esc_html($disc_txt) . '</b>');
     1184                                $msg = sprintf(__('Spend %1$s more for %2$s off!', 'giftvo'), $amount_html, $disc_html);
    10781185                        } else {
    10791186                                /* translators: %s = formatted price amount */
    … …  
    10941201                }
    10951202
    1096                 // One marker per milestone. Markers live in a layer that spans exactly the
    1097                 // track width; the bar is inset by half an icon (9px) so the first/last
    1098                 // marker sits flush with the track ends without the icon overflowing.
    1099                 // Group milestones that share a threshold so their icons never overlap
    1100                 // (e.g. a gift tier and the free-shipping goal both set to the same amount).
    1101                 $groups = [];
    1102                 foreach ($milestones as $m) {
    1103                         $key = number_format($m['threshold'], 2, '.', '');
    1104                         if (!isset($groups[$key])) $groups[$key] = ['threshold' => $m['threshold'], 'gift' => false, 'ship' => false, 'disc' => false];
    1105                         $groups[$key][$m['type']] = true;
    1106                 }
    1107 
     1203                // One marker per group. Markers live in a layer that spans exactly the track
     1204                // width; the bar is inset by half an icon (9px) so the first/last marker sits
     1205                // flush with the track ends without the icon overflowing.
    11081206                $show_amt = !empty($s['show_amounts']);
    11091207                $markers  = '';
    11101208                foreach ($groups as $g) {
    1111                         $left    = $max > 0 ? min(100, max(0, ($g['threshold'] / $max) * 100)) : 0;
    1112                         $reached = ($subtotal + self::EPS) >= $g['threshold'];
    1113                         $ico     = $reached ? 'opacity:1' : 'filter:grayscale(1);opacity:.45';
    1114                         // Connector colour: a reached milestone's line turns gold so it pops
    1115                         // against the green progress bar; unreached stays grey. Applies to plain
     1209                        $left    = min(100, max(0, $g['left']));
     1210                        $reached = ($subtotal + self::EPS) >= $g['max'];
     1211                        // Connector colour: a fully reached group's line turns gold so it pops
     1212                        // against the green progress bar; otherwise it stays grey. Applies to plain
    11161213                        // single stems and the joined U-bracket alike.
    11171214                        $conn    = $reached ? '#e0a516' : '#c7d1cb';
    11181215                        $amt_html = '';
    11191216                        if ($show_amt) {
    1120                                 $amount   = wp_strip_all_tags(wc_price($g['threshold'], ['decimals' => 0]));
     1217                                $amount   = wp_strip_all_tags(wc_price($g['max'], ['decimals' => 0]));
    11211218                                $acol     = $reached ? $accent : '#9aa39c';
    11221219                                $amt_html = '<span style="font-size:10.5px;line-height:1;white-space:nowrap;margin-bottom:2px;font-weight:600;color:' . esc_attr($acol) . '">' . esc_html($amount) . '</span>';
    11231220                        }
    1124                         // Icon(s) for whatever this threshold unlocks; 2+ get a U-shaped bracket.
     1221                        // Icon(s) for whatever this group unlocks, each lit once its own amount is
     1222                        // reached; 2+ icons get a U-shaped bracket.
    11251223                        $spans = [];
    1126                         if ($g['gift']) { $spans[] = '<span style="font-size:13px;line-height:1;' . $ico . '">🎁</span>'; }
    1127                         if ($g['ship']) { $spans[] = '<span style="font-size:13px;line-height:1;' . $ico . '">🚚</span>'; }
    1128                         if ($g['disc']) { $spans[] = '<span style="display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:16px;padding:0 3px;border:1.5px solid ' . esc_attr($conn) . ';border-radius:8px;color:' . esc_attr($conn) . ';font-size:9px;font-weight:800;line-height:1">' . esc_html($disc_txt) . '</span>'; }
     1224                        foreach ($g['items'] as $mm) {
     1225                                $on  = ($subtotal + self::EPS) >= $mm['threshold'];
     1226                                $ico = $on ? 'opacity:1' : 'filter:grayscale(1);opacity:.45';
     1227                                if ($mm['type'] === 'gift') {
     1228                                        $spans[] = '<span style="font-size:13px;line-height:1;' . $ico . '">🎁</span>';
     1229                                } elseif ($mm['type'] === 'ship') {
     1230                                        $spans[] = '<span style="font-size:13px;line-height:1;' . $ico . '">🚚</span>';
     1231                                } else {
     1232                                        $dcol    = $on ? '#e0a516' : '#c7d1cb';
     1233                                        $spans[] = '<span style="display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:16px;padding:0 3px;border:1.5px solid ' . esc_attr($dcol) . ';border-radius:8px;color:' . esc_attr($dcol) . ';font-size:9px;font-weight:800;line-height:1;white-space:nowrap">' . esc_html($mm['label']) . '</span>';
     1234                                }
     1235                        }
    11291236                        $both = count($spans) > 1;
    11301237                        if ($both) {
    … …  
    11401247                                $icons = $spans[0];
    11411248                        }
    1142                         $markers .= '<div class="wctfg-mark" title="' . esc_attr(wp_strip_all_tags(wc_price($g['threshold']))) . '"'
     1249                        $markers .= '<div class="wctfg-mark" title="' . esc_attr(wp_strip_all_tags(wc_price($g['max']))) . '"'
    11431250                                . ' style="position:absolute;top:0;bottom:0;left:' . esc_attr(round($left, 2)) . '%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;z-index:2">'
    11441251                                . $amt_html
  • giftvo/tags/1.2.0/readme.txt

    r3706274 r3741869  
    77WC requires at least: 6.0
    88WC tested up to: 9.3
    9 Stable tag: 1.1.0
     9Stable tag: 1.2.0
    1010License: GPLv2 or later
    1111License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2525* **Cumulative or highest‑only** — stack the gifts of every reached tier, or give just the top one.
    2626* **Live progress bar** — “Spend €X more to unlock a free gift!” with a gift marker for every threshold, on the cart, mini‑cart and via the `[gift_progress]` shortcode.
    27 * **Free‑shipping milestone** — add an optional 🚚 goal to the progress bar so shoppers see how close they are to free shipping (uses the same threshold basis as gifts).
    28 * **Discount milestone** — add an optional % goal marker to the progress bar.
     27* **Free‑shipping goal** — optionally add a 🚚 milestone to the same bar (“Spend €X more for free shipping!”). It’s a plain amount you set, independent of any shipping plugin, and can be switched off in Settings.
     28* **Discount milestones** — add as many % milestones as you like (e.g. 5% from €1,000, 6% from €2,000 …) so shoppers see every step of a tiered discount on the same bar (“Spend €X more for 6% off!”). Giftvo only shows the milestones; the discount itself comes from your pricing or discount plugin.
     29* **Proportional or evenly spaced bar** — place markers at their amount, or give every milestone the same room so many steps stay readable on phones. Milestones at almost the same amount (e.g. free shipping at €999 and a discount at €1,000) share one marker.
    2930* **Crossed‑out value** — show the gift’s normal price struck through next to “Free”.
    3031* **Celebration** — an optional confetti burst when a new gift is unlocked.
    … …  
    3233* **Gateway safe** — the gift is a genuine 0.00 line item; the basket total always matches the amount charged.
    3334* **White‑label ready** — set the menu title, badge text, “Free” label and accent colour; import/export the whole configuration as JSON.
    34 * **Translation ready** — ships with English, German and Turkish; text domain `giftvo`.
     35* **Translation ready** — text domain `giftvo`; translations are delivered through translate.wordpress.org.
    3536* **HPOS compatible** — declares compatibility with WooCommerce High‑Performance Order Storage.
    3637
    … …  
    5556Yes. Set a tier’s mode to “Customer picks one”, add several products, and the shopper chooses one in the cart. Variable gifts also let them pick the colour/variation.
    5657
     58= Does Giftvo apply the discount milestones? =
     59No. Discount milestones are a display feature: they show shoppers how far they are from each discount step. Create the discount itself with your pricing or discount plugin and enter the same amounts and labels in Giftvo.
     60
     61= How many tiers and milestones can I add? =
     62As many as you need — gift tiers and discount milestones are both unlimited. With many milestones, choose the “Evenly spaced” bar scale so the markers stay readable on small screens.
     63
    5764= Does the threshold include tax? =
    5865Your choice. Under Settings you can base it on the subtotal incl. or excl. tax, before or after discounts.
     66
     67== Upgrade Notice ==
     68
     69= 1.2.0 =
     70Unlimited discount milestones and an evenly spaced progress bar option. Existing settings are kept; a single discount milestone is moved into the new list automatically.
    5971
    6072== Screenshots ==
    … …  
    6678== Changelog ==
    6779
     80= 1.2.0 =
     81* New: unlimited discount milestones on the progress bar — add one row per discount step (amount + label, e.g. 1000 → “5%”). A 1.1.0 single discount milestone is moved into the list automatically.
     82* New: “Evenly spaced” progress bar scale — every milestone gets the same room, so bars with many steps stay readable on phones. “Proportional” stays the default.
     83* Milestones at almost the same amount are drawn as one combined marker, and the message names everything the next step unlocks (e.g. “Spend €21 more for free shipping + 5% off!”).
     84* Translations now load from translate.wordpress.org (no bundled language files).
     85
    6886= 1.1.0 =
    69 * New: optional free‑shipping milestone (🚚) on the progress bar, using the same threshold basis as gifts.
    70 * New: optional discount milestone (%) on the progress bar.
    71 * Minor UI and stability improvements.
     87* New: optional free‑shipping goal on the progress bar — adds a 🚚 milestone (“Spend €X more for free shipping!”) alongside the gift markers. Set the amount and toggle it in Settings; it’s independent of any shipping plugin.
     88* Markers that share the same threshold (e.g. a gift tier and the free‑shipping goal set to the same amount) are merged into a single combined marker so the icons never overlap.
     89* Updated English, German and Turkish translations.
    7290
    7391= 1.0.0 =
    … …  
    8199* White‑label options (menu title, badge, label, accent), JSON import/export, uninstall cleanup.
    82100* English, German and Turkish translations. HPOS compatible.
    83 
    84 == Upgrade Notice ==
    85 
    86 = 1.1.0 =
    87 Adds optional free‑shipping (🚚) and discount (%) milestones to the progress bar.
  • giftvo/trunk/giftvo.php

    r3706274 r3741869  
    22/**
    33 * Plugin Name: Giftvo - Tiered Gifts for WooCommerce
    4  * Description: Automatically adds free gift product(s) to the cart when the cart total reaches a threshold you configure. Unlimited tiers, "let the customer pick" gifts, colour/variation choice, a live progress bar with an optional free-shipping goal, and a real 0.00 line item that is safe for every payment gateway.
    5  * Version: 1.1.0
     4 * Description: Automatically adds free gift product(s) to the cart when the cart total reaches a threshold you configure. Unlimited tiers, "let the customer pick" gifts, colour/variation choice, a live progress bar with optional free-shipping and discount milestones, and a real 0.00 line item that is safe for every payment gateway.
     5 * Version: 1.2.0
    66 * Author: Serdar Keskin
    77 * Author URI: https://zaundiy.de
    … …  
    2626        const FLAG     = 'wctfg_gift';       // cart item marker (= threshold value)
    2727        const EPS      = 0.00001;
     28        const VERSION  = '1.2.0';
     29        // Milestones closer than this share of the bar's top amount are drawn as one marker.
     30        const NEAR     = 0.03;
    2831
    2932        private static $settings_cache = null;
    3033
    3134        public function __construct() {
    32                 // i18n — bundled /languages translations (loads on init per WP 6.7+ guidance).
    33                 // A site-wide override in wp-content/languages/plugins/ still takes precedence.
    34                 add_action('init', [$this, 'load_textdomain']);
    35 
    3635                // Admin
    3736                add_action('admin_menu',                 [$this, 'admin_menu']);
    … …  
    6059                add_action('woocommerce_before_cart',                       [$this, 'progress_on_cart']);
    6160                add_action('woocommerce_widget_shopping_cart_before_buttons', [$this, 'progress_on_minicart']);
    62         }
    63 
    64         public function load_textdomain() {
    65                 load_plugin_textdomain('giftvo', false, dirname(plugin_basename(__FILE__)) . '/languages');
    6661        }
    6762
    … …  
    8580                        'free_ship_amount' => '',
    8681                        'discount_enable'  => 0,
    87                         'discount_amount'  => '',
    88                         'discount_percent' => '',
     82                        'discount_tiers'   => [],        // [['amount' => float, 'label' => string], …]
     83                        'discount_amount'  => '',        // legacy (≤ 1.1.0): single discount milestone
     84                        'discount_percent' => '',        // legacy (≤ 1.1.0)
     85                        'bar_scale'        => 'linear',  // linear|steps
    8986                ];
    9087                $s = get_option(self::SETTINGS, []);
    91                 self::$settings_cache = array_merge($defaults, is_array($s) ? $s : []);
     88                $s = array_merge($defaults, is_array($s) ? $s : []);
     89
     90                // Upgrade path: a single 1.1.0 discount milestone becomes the first row of the list.
     91                $s['discount_tiers'] = self::clean_discount_tiers($s['discount_tiers']);
     92                if (!$s['discount_tiers'] && (float) $s['discount_amount'] > 0) {
     93                        $s['discount_tiers'] = self::clean_discount_tiers([['amount' => $s['discount_amount'], 'label' => $s['discount_percent']]]);
     94                }
     95                $s['bar_scale'] = ($s['bar_scale'] === 'steps') ? 'steps' : 'linear';
     96
     97                self::$settings_cache = $s;
    9298                return self::$settings_cache;
     99        }
     100
     101        /** Sanitise discount milestones: [['amount' => float > 0, 'label' => string], …] sorted by amount. */
     102        private static function clean_discount_tiers($rows) {
     103                $out = [];
     104                foreach ((array) $rows as $r) {
     105                        if (!is_array($r)) continue;
     106                        $amount = isset($r['amount']) ? (float) str_replace(',', '.', (string) $r['amount']) : 0;
     107                        if ($amount <= 0) continue;
     108                        $out[] = ['amount' => $amount, 'label' => isset($r['label']) ? sanitize_text_field((string) $r['label']) : ''];
     109                }
     110                usort($out, function ($a, $b) { return $a['amount'] <=> $b['amount']; });
     111                return $out;
    93112        }
    94113
    … …  
    162181                wp_enqueue_style('select2');
    163182
    164                 wp_register_script('wctfg-admin', '', ['jquery', 'wc-enhanced-select'], '1.1.0', true);
     183                wp_register_script('wctfg-admin', '', ['jquery', 'wc-enhanced-select'], self::VERSION, true);
    165184                wp_enqueue_script('wctfg-admin');
    166185                wp_localize_script('wctfg-admin', 'WctfgAdmin', [
    … …  
    217236                        if ($('.wctfg-row').length > 1) $(this).closest('tr').remove();
    218237                        else { $(this).closest('tr').find('input').val(''); $(this).closest('tr').find('select.wc-product-search').val(null).trigger('change'); }
     238                });
     239                $('#wctfg-disc-add').on('click', function(){
     240                        var $row = $('#wctfg-disc-table .wctfg-disc-row:first').clone();
     241                        $row.find('input').val('');
     242                        $('#wctfg-disc-table tbody').append($row);
     243                });
     244                $('#wctfg-disc-table').on('click', '.wctfg-disc-del', function(){
     245                        if ($('#wctfg-disc-table .wctfg-disc-row').length > 1) $(this).closest('tr').remove();
     246                        else $(this).closest('tr').find('input').val('');
    219247                });
    220248        });
    … …  
    338366                                        </tr>
    339367                                        <tr>
    340                                                 <th scope="row"><?php echo esc_html__('Discount amount', 'giftvo'); ?></th>
     368                                                <th scope="row"><?php echo esc_html__('Discount milestones', 'giftvo'); ?></th>
    341369                                                <td>
    342                                                         <input type="number" step="0.01" min="0" name="discount_amount" value="<?php echo esc_attr($s['discount_amount']); ?>" style="width:150px">
    343                                                         <p class="description"><?php echo esc_html__('Cart amount that unlocks the discount. Uses the same threshold basis as gifts.', 'giftvo'); ?></p>
     370                                                        <?php $disc_rows = $s['discount_tiers'] ? $s['discount_tiers'] : [['amount' => '', 'label' => '']]; ?>
     371                                                        <table class="widefat striped" id="wctfg-disc-table" style="max-width:460px">
     372                                                                <thead>
     373                                                                        <tr>
     374                                                                                <th><?php echo esc_html__('Cart amount', 'giftvo'); ?></th>
     375                                                                                <th><?php echo esc_html__('Label', 'giftvo'); ?></th>
     376                                                                                <th style="width:50px"></th>
     377                                                                        </tr>
     378                                                                </thead>
     379                                                                <tbody>
     380                                                                        <?php foreach ($disc_rows as $d): ?>
     381                                                                        <tr class="wctfg-disc-row">
     382                                                                                <td><input type="number" step="0.01" min="0" name="disc_amount[]" value="<?php echo esc_attr($d['amount']); ?>" style="width:150px"></td>
     383                                                                                <td><input type="text" name="disc_label[]" value="<?php echo esc_attr($d['label']); ?>" placeholder="5%" style="width:120px"></td>
     384                                                                                <td><button type="button" class="button wctfg-disc-del" title="<?php echo esc_attr__('Remove row', 'giftvo'); ?>">✕</button></td>
     385                                                                        </tr>
     386                                                                        <?php endforeach; ?>
     387                                                                </tbody>
     388                                                        </table>
     389                                                        <p><button type="button" class="button" id="wctfg-disc-add">+ <?php echo esc_html__('Add discount milestone', 'giftvo'); ?></button></p>
     390                                                        <p class="description" style="max-width:640px"><?php echo esc_html__('One marker per row, e.g. 1000 → “5%”, 2000 → “6%”. The label is shown on the marker and in the message. Uses the same threshold basis as gifts. Giftvo only displays these milestones — set up the discount itself in your pricing or discount plugin.', 'giftvo'); ?></p>
    344391                                                </td>
    345392                                        </tr>
    346393                                        <tr>
    347                                                 <th scope="row"><?php echo esc_html__('Discount label', 'giftvo'); ?></th>
     394                                                <th scope="row"><?php echo esc_html__('Progress bar scale', 'giftvo'); ?></th>
    348395                                                <td>
    349                                                         <input type="text" name="discount_percent" value="<?php echo esc_attr($s['discount_percent']); ?>" placeholder="5%" style="width:120px">
    350                                                         <p class="description"><?php echo esc_html__('Shown on the marker and in the message, e.g. “5%”.', 'giftvo'); ?></p>
     396                                                        <label><input type="radio" name="bar_scale" value="linear" <?php checked($s['bar_scale'], 'linear'); ?>> <?php echo esc_html__('Proportional — each marker sits at its amount', 'giftvo'); ?></label><br>
     397                                                        <label><input type="radio" name="bar_scale" value="steps" <?php checked($s['bar_scale'], 'steps'); ?>> <?php echo esc_html__('Evenly spaced — every milestone gets the same room (best for many milestones)', 'giftvo'); ?></label>
    351398                                                </td>
    352399                                        </tr>
    … …  
    414461                usort($tiers, function ($a, $b) { return $a['threshold'] <=> $b['threshold']; });
    415462                update_option(self::OPTION, $tiers, false);
     463
     464                // Discount milestones: two parallel lists, one entry per table row.
     465                $d_amounts = isset($_POST['disc_amount']) ? array_map('sanitize_text_field', wp_unslash((array) $_POST['disc_amount'])) : [];
     466                $d_labels  = isset($_POST['disc_label'])  ? array_values(array_map('sanitize_text_field', wp_unslash((array) $_POST['disc_label']))) : [];
     467                $disc      = [];
     468                foreach (array_values($d_amounts) as $i => $amount) {
     469                        $disc[] = ['amount' => $amount, 'label' => $d_labels[$i] ?? ''];
     470                }
    416471
    417472                // Settings
    … …  
    434489                        'free_ship_amount' => isset($_POST['free_ship_amount']) ? max(0, (float) str_replace(',', '.', sanitize_text_field(wp_unslash($_POST['free_ship_amount'])))) : 0,
    435490                        'discount_enable'  => !empty($_POST['discount_enable']) ? 1 : 0,
    436                         'discount_amount'  => isset($_POST['discount_amount']) ? max(0, (float) str_replace(',', '.', sanitize_text_field(wp_unslash($_POST['discount_amount'])))) : 0,
    437                         'discount_percent' => isset($_POST['discount_percent']) ? sanitize_text_field(wp_unslash($_POST['discount_percent'])) : '',
     491                        'discount_tiers'   => self::clean_discount_tiers($disc),
     492                        'discount_amount'  => '', // legacy single milestone now lives in discount_tiers
     493                        'discount_percent' => '',
     494                        'bar_scale'        => (isset($_POST['bar_scale']) && sanitize_key(wp_unslash($_POST['bar_scale'])) === 'steps') ? 'steps' : 'linear',
    438495                ];
    439496                update_option(self::SETTINGS, $settings, false);
    … …  
    473530                        // Re-run through the same whitelist as save.
    474531                        $in = $data['settings'];
     532                        // Accept the 1.2.0 list as well as a 1.1.0 export with a single discount milestone.
     533                        $disc = self::clean_discount_tiers($in['discount_tiers'] ?? []);
     534                        if (!$disc && isset($in['discount_amount']) && (float) str_replace(',', '.', (string) $in['discount_amount']) > 0) {
     535                                $disc = self::clean_discount_tiers([['amount' => $in['discount_amount'], 'label' => $in['discount_percent'] ?? '']]);
     536                        }
    475537                        $accent = isset($in['accent']) ? trim((string) $in['accent']) : '';
    476538                        update_option(self::SETTINGS, [
    … …  
    489551                                'free_ship_amount' => isset($in['free_ship_amount']) ? max(0, (float) str_replace(',', '.', (string) $in['free_ship_amount'])) : 0,
    490552                                'discount_enable'  => !empty($in['discount_enable']) ? 1 : 0,
    491                                 'discount_amount'  => isset($in['discount_amount']) ? max(0, (float) str_replace(',', '.', (string) $in['discount_amount'])) : 0,
    492                                 'discount_percent' => isset($in['discount_percent']) ? sanitize_text_field((string) $in['discount_percent']) : '',
     553                                'discount_tiers'   => $disc,
     554                                'discount_amount'  => '',
     555                                'discount_percent' => '',
     556                                'bar_scale'        => (($in['bar_scale'] ?? '') === 'steps') ? 'steps' : 'linear',
    493557                        ], false);
    494558                }
    … …  
    10201084                $accent = $this->accent();
    10211085
    1022                 // Two independent, separately-toggled milestone sources feed one bar:
    1023                 // gift tiers (🎁, gated by "progress_bar") and a free-shipping goal (🚚,
    1024                 // gated by "free_ship_enable"). Either source alone renders the bar.
     1086                // Three separately-toggled milestone sources feed one bar: gift tiers (🎁,
     1087                // gated by "progress_bar"), a free-shipping goal (🚚, "free_ship_enable") and
     1088                // any number of discount milestones (%, "discount_enable"). Any one alone
     1089                // renders the bar.
    10251090                $show_gifts = !empty($s['progress_bar']);
    10261091                $ship_amt   = (float) $s['free_ship_amount'];
    10271092                $show_ship  = !empty($s['free_ship_enable']) && $ship_amt > 0;
    1028                 $disc_amt   = (float) $s['discount_amount'];
    1029                 $disc_txt   = trim((string) $s['discount_percent']);
    1030                 if ($disc_txt === '') { $disc_txt = '%'; }
    1031                 $show_disc  = !empty($s['discount_enable']) && $disc_amt > 0;
     1093                $show_disc  = !empty($s['discount_enable']) && !empty($s['discount_tiers']);
    10321094
    10331095                $milestones = [];
    10341096                if ($show_gifts) {
    10351097                        foreach ($this->normalized_tiers() as $t) {
    1036                                 $milestones[] = ['threshold' => (float) $t['threshold'], 'type' => 'gift'];
     1098                                $milestones[] = ['threshold' => (float) $t['threshold'], 'type' => 'gift', 'label' => ''];
    10371099                        }
    10381100                }
    10391101                if ($show_ship) {
    1040                         $milestones[] = ['threshold' => $ship_amt, 'type' => 'ship'];
     1102                        $milestones[] = ['threshold' => $ship_amt, 'type' => 'ship', 'label' => ''];
    10411103                }
    10421104                if ($show_disc) {
    1043                         $milestones[] = ['threshold' => $disc_amt, 'type' => 'disc'];
     1105                        foreach ($s['discount_tiers'] as $d) {
     1106                                $milestones[] = ['threshold' => (float) $d['amount'], 'type' => 'disc', 'label' => ($d['label'] !== '' ? $d['label'] : '%')];
     1107                        }
    10441108                }
    10451109                if (empty($milestones)) return '';
    … …  
    10471111
    10481112                $subtotal = $this->base_subtotal(WC()->cart);
    1049 
    1050                 $max = 0.0;
    1051                 foreach ($milestones as $m) $max = max($max, $m['threshold']);
    1052                 $pct = $max > 0 ? min(100, ($subtotal / $max) * 100) : 0;
    1053 
    1054                 // Next unreached milestone (smallest threshold still above the subtotal).
     1113                $max      = (float) $milestones[count($milestones) - 1]['threshold'];
     1114
     1115                // Group milestones that are (nearly) at the same amount so their icons never
     1116                // overlap, e.g. free shipping at 999 and a discount at 1000. A group is drawn
     1117                // at its highest amount, so a marker never promises a reward too early.
     1118                $groups = [];
     1119                foreach ($milestones as $m) {
     1120                        $last = count($groups) - 1;
     1121                        if ($last >= 0 && ($m['threshold'] - $groups[$last]['min']) <= $max * self::NEAR) {
     1122                                $groups[$last]['items'][] = $m;
     1123                                $groups[$last]['max']     = $m['threshold'];
     1124                        } else {
     1125                                $groups[] = ['min' => $m['threshold'], 'max' => $m['threshold'], 'items' => [$m]];
     1126                        }
     1127                }
     1128                $n     = count($groups);
     1129                $steps = ($s['bar_scale'] === 'steps');
     1130
     1131                // Marker position and bar fill. "linear": proportional to the amount.
     1132                // "steps": every group gets the same room; the fill moves proportionally
     1133                // inside the step the customer is currently in.
     1134                foreach ($groups as $i => $g) {
     1135                        $groups[$i]['left'] = $steps ? (($i + 1) / $n) * 100 : ($max > 0 ? ($g['max'] / $max) * 100 : 0);
     1136                }
     1137                if ($steps) {
     1138                        $pct = 100;
     1139                        $prev = 0.0;
     1140                        foreach ($groups as $i => $g) {
     1141                                if ($subtotal + self::EPS < $g['max']) {
     1142                                        $span = max(self::EPS, $g['max'] - $prev);
     1143                                        $pct  = (($i + max(0, min(1, ($subtotal - $prev) / $span))) / $n) * 100;
     1144                                        break;
     1145                                }
     1146                                $prev = $g['max'];
     1147                        }
     1148                } else {
     1149                        $pct = $max > 0 ? min(100, ($subtotal / $max) * 100) : 0;
     1150                }
     1151
     1152                // Message: the next group not yet fully reached and everything it still unlocks.
    10551153                $next = null;
    1056                 foreach ($milestones as $m) { if ($m['threshold'] > $subtotal + self::EPS) { $next = $m; break; } }
     1154                foreach ($groups as $g) { if ($g['max'] > $subtotal + self::EPS) { $next = $g; break; } }
    10571155
    10581156                if ($next) {
    1059                         $remaining   = $next['threshold'] - $subtotal;
     1157                        $remaining   = $next['max'] - $subtotal;
    10601158                        $amount_html = '<b style="color:' . esc_attr($accent) . '">' . wp_kses_post(wc_price($remaining)) . '</b>';
    1061                         $next_gift = false; $next_ship = false; $next_disc = false;
    1062                         foreach ($milestones as $mm) {
    1063                                 if (abs($mm['threshold'] - $next['threshold']) < self::EPS) {
    1064                                         if ($mm['type'] === 'ship') { $next_ship = true; }
    1065                                         elseif ($mm['type'] === 'disc') { $next_disc = true; }
    1066                                         else { $next_gift = true; }
    1067                                 }
    1068                         }
    1069                         if ($next_gift && $next_ship) {
     1159                        $next_gift = false; $next_ship = false; $disc_txt = '';
     1160                        foreach ($next['items'] as $mm) {
     1161                                if ($mm['threshold'] <= $subtotal + self::EPS) continue; // already unlocked
     1162                                if ($mm['type'] === 'ship') { $next_ship = true; }
     1163                                elseif ($mm['type'] === 'disc') { $disc_txt = $mm['label']; } // highest discount in the group wins
     1164                                else { $next_gift = true; }
     1165                        }
     1166                        $disc_html = '<b style="color:' . esc_attr($accent) . '">' . esc_html($disc_txt) . '</b>';
     1167                        if ($next_gift && $next_ship && $disc_txt !== '') {
     1168                                /* translators: %1$s = formatted price amount, %2$s = discount label such as "5%" */
     1169                                $msg = sprintf(__('Spend %1$s more for a free gift, free shipping + %2$s off!', 'giftvo'), $amount_html, $disc_html);
     1170                        } elseif ($next_gift && $next_ship) {
    10701171                                /* translators: %s = formatted price amount */
    10711172                                $msg = sprintf(__('Spend %s more for a free gift + free shipping!', 'giftvo'), $amount_html);
     1173                        } elseif ($next_gift && $disc_txt !== '') {
     1174                                /* translators: %1$s = formatted price amount, %2$s = discount label such as "5%" */
     1175                                $msg = sprintf(__('Spend %1$s more for a free gift + %2$s off!', 'giftvo'), $amount_html, $disc_html);
     1176                        } elseif ($next_ship && $disc_txt !== '') {
     1177                                /* translators: %1$s = formatted price amount, %2$s = discount label such as "5%" */
     1178                                $msg = sprintf(__('Spend %1$s more for free shipping + %2$s off!', 'giftvo'), $amount_html, $disc_html);
    10721179                        } elseif ($next_ship) {
    10731180                                /* translators: %s = formatted price amount */
    10741181                                $msg = sprintf(__('Spend %s more for free shipping!', 'giftvo'), $amount_html);
    1075                         } elseif ($next_disc && !$next_gift) {
     1182                        } elseif ($disc_txt !== '') {
    10761183                                /* translators: %1$s = amount, %2$s = discount label such as "5%" */
    1077                                 $msg = sprintf(__('Spend %1$s more for %2$s off!', 'giftvo'), $amount_html, '<b style="color:' . esc_attr($accent) . '">' . esc_html($disc_txt) . '</b>');
     1184                                $msg = sprintf(__('Spend %1$s more for %2$s off!', 'giftvo'), $amount_html, $disc_html);
    10781185                        } else {
    10791186                                /* translators: %s = formatted price amount */
    … …  
    10941201                }
    10951202
    1096                 // One marker per milestone. Markers live in a layer that spans exactly the
    1097                 // track width; the bar is inset by half an icon (9px) so the first/last
    1098                 // marker sits flush with the track ends without the icon overflowing.
    1099                 // Group milestones that share a threshold so their icons never overlap
    1100                 // (e.g. a gift tier and the free-shipping goal both set to the same amount).
    1101                 $groups = [];
    1102                 foreach ($milestones as $m) {
    1103                         $key = number_format($m['threshold'], 2, '.', '');
    1104                         if (!isset($groups[$key])) $groups[$key] = ['threshold' => $m['threshold'], 'gift' => false, 'ship' => false, 'disc' => false];
    1105                         $groups[$key][$m['type']] = true;
    1106                 }
    1107 
     1203                // One marker per group. Markers live in a layer that spans exactly the track
     1204                // width; the bar is inset by half an icon (9px) so the first/last marker sits
     1205                // flush with the track ends without the icon overflowing.
    11081206                $show_amt = !empty($s['show_amounts']);
    11091207                $markers  = '';
    11101208                foreach ($groups as $g) {
    1111                         $left    = $max > 0 ? min(100, max(0, ($g['threshold'] / $max) * 100)) : 0;
    1112                         $reached = ($subtotal + self::EPS) >= $g['threshold'];
    1113                         $ico     = $reached ? 'opacity:1' : 'filter:grayscale(1);opacity:.45';
    1114                         // Connector colour: a reached milestone's line turns gold so it pops
    1115                         // against the green progress bar; unreached stays grey. Applies to plain
     1209                        $left    = min(100, max(0, $g['left']));
     1210                        $reached = ($subtotal + self::EPS) >= $g['max'];
     1211                        // Connector colour: a fully reached group's line turns gold so it pops
     1212                        // against the green progress bar; otherwise it stays grey. Applies to plain
    11161213                        // single stems and the joined U-bracket alike.
    11171214                        $conn    = $reached ? '#e0a516' : '#c7d1cb';
    11181215                        $amt_html = '';
    11191216                        if ($show_amt) {
    1120                                 $amount   = wp_strip_all_tags(wc_price($g['threshold'], ['decimals' => 0]));
     1217                                $amount   = wp_strip_all_tags(wc_price($g['max'], ['decimals' => 0]));
    11211218                                $acol     = $reached ? $accent : '#9aa39c';
    11221219                                $amt_html = '<span style="font-size:10.5px;line-height:1;white-space:nowrap;margin-bottom:2px;font-weight:600;color:' . esc_attr($acol) . '">' . esc_html($amount) . '</span>';
    11231220                        }
    1124                         // Icon(s) for whatever this threshold unlocks; 2+ get a U-shaped bracket.
     1221                        // Icon(s) for whatever this group unlocks, each lit once its own amount is
     1222                        // reached; 2+ icons get a U-shaped bracket.
    11251223                        $spans = [];
    1126                         if ($g['gift']) { $spans[] = '<span style="font-size:13px;line-height:1;' . $ico . '">🎁</span>'; }
    1127                         if ($g['ship']) { $spans[] = '<span style="font-size:13px;line-height:1;' . $ico . '">🚚</span>'; }
    1128                         if ($g['disc']) { $spans[] = '<span style="display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:16px;padding:0 3px;border:1.5px solid ' . esc_attr($conn) . ';border-radius:8px;color:' . esc_attr($conn) . ';font-size:9px;font-weight:800;line-height:1">' . esc_html($disc_txt) . '</span>'; }
     1224                        foreach ($g['items'] as $mm) {
     1225                                $on  = ($subtotal + self::EPS) >= $mm['threshold'];
     1226                                $ico = $on ? 'opacity:1' : 'filter:grayscale(1);opacity:.45';
     1227                                if ($mm['type'] === 'gift') {
     1228                                        $spans[] = '<span style="font-size:13px;line-height:1;' . $ico . '">🎁</span>';
     1229                                } elseif ($mm['type'] === 'ship') {
     1230                                        $spans[] = '<span style="font-size:13px;line-height:1;' . $ico . '">🚚</span>';
     1231                                } else {
     1232                                        $dcol    = $on ? '#e0a516' : '#c7d1cb';
     1233                                        $spans[] = '<span style="display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:16px;padding:0 3px;border:1.5px solid ' . esc_attr($dcol) . ';border-radius:8px;color:' . esc_attr($dcol) . ';font-size:9px;font-weight:800;line-height:1;white-space:nowrap">' . esc_html($mm['label']) . '</span>';
     1234                                }
     1235                        }
    11291236                        $both = count($spans) > 1;
    11301237                        if ($both) {
    … …  
    11401247                                $icons = $spans[0];
    11411248                        }
    1142                         $markers .= '<div class="wctfg-mark" title="' . esc_attr(wp_strip_all_tags(wc_price($g['threshold']))) . '"'
     1249                        $markers .= '<div class="wctfg-mark" title="' . esc_attr(wp_strip_all_tags(wc_price($g['max']))) . '"'
    11431250                                . ' style="position:absolute;top:0;bottom:0;left:' . esc_attr(round($left, 2)) . '%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;z-index:2">'
    11441251                                . $amt_html
  • giftvo/trunk/readme.txt

    r3706274 r3741869  
    77WC requires at least: 6.0
    88WC tested up to: 9.3
    9 Stable tag: 1.1.0
     9Stable tag: 1.2.0
    1010License: GPLv2 or later
    1111License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    2525* **Cumulative or highest‑only** — stack the gifts of every reached tier, or give just the top one.
    2626* **Live progress bar** — “Spend €X more to unlock a free gift!” with a gift marker for every threshold, on the cart, mini‑cart and via the `[gift_progress]` shortcode.
    27 * **Free‑shipping milestone** — add an optional 🚚 goal to the progress bar so shoppers see how close they are to free shipping (uses the same threshold basis as gifts).
    28 * **Discount milestone** — add an optional % goal marker to the progress bar.
     27* **Free‑shipping goal** — optionally add a 🚚 milestone to the same bar (“Spend €X more for free shipping!”). It’s a plain amount you set, independent of any shipping plugin, and can be switched off in Settings.
     28* **Discount milestones** — add as many % milestones as you like (e.g. 5% from €1,000, 6% from €2,000 …) so shoppers see every step of a tiered discount on the same bar (“Spend €X more for 6% off!”). Giftvo only shows the milestones; the discount itself comes from your pricing or discount plugin.
     29* **Proportional or evenly spaced bar** — place markers at their amount, or give every milestone the same room so many steps stay readable on phones. Milestones at almost the same amount (e.g. free shipping at €999 and a discount at €1,000) share one marker.
    2930* **Crossed‑out value** — show the gift’s normal price struck through next to “Free”.
    3031* **Celebration** — an optional confetti burst when a new gift is unlocked.
    … …  
    3233* **Gateway safe** — the gift is a genuine 0.00 line item; the basket total always matches the amount charged.
    3334* **White‑label ready** — set the menu title, badge text, “Free” label and accent colour; import/export the whole configuration as JSON.
    34 * **Translation ready** — ships with English, German and Turkish; text domain `giftvo`.
     35* **Translation ready** — text domain `giftvo`; translations are delivered through translate.wordpress.org.
    3536* **HPOS compatible** — declares compatibility with WooCommerce High‑Performance Order Storage.
    3637
    … …  
    5556Yes. Set a tier’s mode to “Customer picks one”, add several products, and the shopper chooses one in the cart. Variable gifts also let them pick the colour/variation.
    5657
     58= Does Giftvo apply the discount milestones? =
     59No. Discount milestones are a display feature: they show shoppers how far they are from each discount step. Create the discount itself with your pricing or discount plugin and enter the same amounts and labels in Giftvo.
     60
     61= How many tiers and milestones can I add? =
     62As many as you need — gift tiers and discount milestones are both unlimited. With many milestones, choose the “Evenly spaced” bar scale so the markers stay readable on small screens.
     63
    5764= Does the threshold include tax? =
    5865Your choice. Under Settings you can base it on the subtotal incl. or excl. tax, before or after discounts.
     66
     67== Upgrade Notice ==
     68
     69= 1.2.0 =
     70Unlimited discount milestones and an evenly spaced progress bar option. Existing settings are kept; a single discount milestone is moved into the new list automatically.
    5971
    6072== Screenshots ==
    … …  
    6678== Changelog ==
    6779
     80= 1.2.0 =
     81* New: unlimited discount milestones on the progress bar — add one row per discount step (amount + label, e.g. 1000 → “5%”). A 1.1.0 single discount milestone is moved into the list automatically.
     82* New: “Evenly spaced” progress bar scale — every milestone gets the same room, so bars with many steps stay readable on phones. “Proportional” stays the default.
     83* Milestones at almost the same amount are drawn as one combined marker, and the message names everything the next step unlocks (e.g. “Spend €21 more for free shipping + 5% off!”).
     84* Translations now load from translate.wordpress.org (no bundled language files).
     85
    6886= 1.1.0 =
    69 * New: optional free‑shipping milestone (🚚) on the progress bar, using the same threshold basis as gifts.
    70 * New: optional discount milestone (%) on the progress bar.
    71 * Minor UI and stability improvements.
     87* New: optional free‑shipping goal on the progress bar — adds a 🚚 milestone (“Spend €X more for free shipping!”) alongside the gift markers. Set the amount and toggle it in Settings; it’s independent of any shipping plugin.
     88* Markers that share the same threshold (e.g. a gift tier and the free‑shipping goal set to the same amount) are merged into a single combined marker so the icons never overlap.
     89* Updated English, German and Turkish translations.
    7290
    7391= 1.0.0 =
    … …  
    8199* White‑label options (menu title, badge, label, accent), JSON import/export, uninstall cleanup.
    82100* English, German and Turkish translations. HPOS compatible.
    83 
    84 == Upgrade Notice ==
    85 
    86 = 1.1.0 =
    87 Adds optional free‑shipping (🚚) and discount (%) milestones to the progress bar.
Note: See TracChangeset for help on using the changeset viewer.