Skip to content

Instantly share code, notes, and snippets.

@goranefbl
Last active June 4, 2025 13:36
Show Gist options
  • Save goranefbl/796c0fb9e99eb5d01e43f564b4cfe7fd to your computer and use it in GitHub Desktop.
Save goranefbl/796c0fb9e99eb5d01e43f564b4cfe7fd to your computer and use it in GitHub Desktop.
class-points-checkout.php
<?php
/**
* Handles points redemption during checkout process
*/
class WPGL_Points_Checkout
{
private static $instance = null;
const COUPON_PREFIX = 'loyalty_points_';
const EXPIRATION_TIME = 3600; // 1 hour in seconds
public static function init()
{
if (self::$instance === null) {
self::$instance = new self();
}
self::register_hooks();
}
public static function register_hooks()
{
// Points earning display - before proceed to checkout button and before checkout form
add_action('woocommerce_proceed_to_checkout', [__CLASS__, 'display_points_to_be_earned'], 5);
add_action('woocommerce_checkout_order_review', [__CLASS__, 'display_points_to_be_earned'], 10);
// Points conversion notice
// add_action('woocommerce_before_cart', [__CLASS__, 'display_points_conversion_notice']);
add_action('woocommerce_checkout_order_review', [__CLASS__, 'display_points_conversion_notice']);
add_filter('woocommerce_update_order_review_fragments', [__CLASS__, 'update_points_conversion_fragments']);
// AJAX handlers
add_action('wp_ajax_apply_points_discount', [__CLASS__, 'apply_points_discount']);
add_action('wp_ajax_remove_points_discount', [__CLASS__, 'remove_points_discount']);
add_action('wp_ajax_check_points_applied', [__CLASS__, 'check_points_applied']);
// Cleanup hooks
add_action('woocommerce_cart_emptied', [__CLASS__, 'clear_points_discount']);
// Points deduction - only when order is actually being processed
add_action('woocommerce_checkout_order_processed', [__CLASS__, 'process_points_deduction'], 20, 3);
add_action('woocommerce_store_api_checkout_order_processed', [__CLASS__, 'process_points_deduction'], 20, 3);
// Calculate points when order is created
add_action('woocommerce_new_order', [__CLASS__, 'calculate_order_points'], 10, 1);
add_action('woocommerce_checkout_order_processed', [__CLASS__, 'calculate_order_points'], 10, 1);
// Localize script data
add_action('wp_enqueue_scripts', [__CLASS__, 'localize_points_data']);
// Handle virtual coupon validation
add_filter('woocommerce_get_shop_coupon_data', [__CLASS__, 'get_virtual_coupon_data'], 10, 2);
// Customize coupon display for standard checkout
add_filter('woocommerce_cart_totals_coupon_label', [__CLASS__, 'customize_coupon_label'], 10, 2);
// Add this method to support AJAX fragment updates
add_filter('woocommerce_update_order_review_fragments', [__CLASS__, 'update_points_earning_fragment']);
}
/**
* Get applied points coupon data from cart
*
* @return array|false Coupon data or false if no points coupon applied
*/
private static function get_applied_points_coupon() {
if (!function_exists('WC')) {
return false;
}
/** @disregard */
$cart = WC()->cart;
if (!$cart) {
return false;
}
$applied_coupons = $cart->get_applied_coupons();
foreach ($applied_coupons as $coupon_code) {
if (strpos($coupon_code, self::COUPON_PREFIX) === 0) {
$coupon_data = get_transient('loyalty_coupon_' . $coupon_code);
if ($coupon_data && $coupon_data['user_id'] === get_current_user_id()) {
return array_merge($coupon_data, ['coupon_code' => $coupon_code]);
}
}
}
return false;
}
/**
* Core method to apply points discount - used by both block and non-block
*
* @param int $points Points to apply
* @param bool $is_block Whether this is called from block checkout
* @return array|WP_Error Array with points and amount on success, WP_Error on failure
*/
private static function apply_points_discount_core($points, $is_block = false) {
$user_id = get_current_user_id();
if (!$user_id) {
return $is_block ? ['error' => 'User not logged in'] : wp_send_json_error('User not logged in');
}
$settings = WPGL_Points_Core::get_settings();
if (!$settings['conversionRateEnabled']) {
return $is_block ? ['error' => 'Points redemption disabled'] : wp_send_json_error('Points redemption disabled');
}
$user_points = WPGL_Database::get_user_points($user_id);
$conversion_rate = $settings['conversionRate'];
WPGL_Logger::points("Starting points discount application", [
'user_id' => $user_id,
'user_points' => $user_points,
'requested_points' => $points,
'conversion_rate' => $conversion_rate,
'is_block' => $is_block
]);
// Check minimum points requirement
if ($user_points < $conversion_rate['minPoints']) {
return $is_block ? ['error' => 'Not enough points'] : wp_send_json_error('Not enough points');
}
// Get cart total - check if WooCommerce is active
if (!function_exists('WC') || !function_exists('wc_price')) {
return $is_block ? ['error' => 'WooCommerce not active'] : wp_send_json_error('WooCommerce not active');
}
/** @disregard */
$cart = WC()->cart;
if (!$cart) {
return $is_block ? ['error' => 'Cart not available'] : wp_send_json_error('Cart not available');
}
// Validate requested points
if ($points < $conversion_rate['minPoints']) {
return $is_block ? ['error' => 'Minimum points requirement not met'] : wp_send_json_error('Minimum points requirement not met');
}
if ($points > $user_points) {
return $is_block ? ['error' => 'Not enough points'] : wp_send_json_error('Not enough points');
}
if ($points > $conversion_rate['maxPoints']) {
$points = $conversion_rate['maxPoints'];
}
// Calculate discount amount (1 point = 1 currency)
$discount_amount = ($points / $conversion_rate['points']) * $conversion_rate['value'];
// Get cart total for validation
$cart_total = $cart->get_total('edit');
// Cap discount at cart total
if ($discount_amount > $cart_total) {
$discount_amount = $cart_total;
$points = ceil(($cart_total * $conversion_rate['points']) / $conversion_rate['value']);
}
// Remove existing points coupon if any
self::remove_existing_points_coupon();
// Create and apply virtual coupon
$coupon_code = self::COUPON_PREFIX . $user_id . '_' . time();
$coupon_applied = self::apply_virtual_coupon($coupon_code, $discount_amount, $points);
if (!$coupon_applied) {
return $is_block ? ['error' => 'Failed to apply discount'] : wp_send_json_error('Failed to apply discount');
}
WPGL_Logger::points("Successfully applied points coupon", [
'coupon_code' => $coupon_code,
'discount_amount' => $discount_amount,
'points' => $points
]);
$result = [
'points' => $points,
'amount' => $discount_amount
];
return $is_block ? $result : wp_send_json_success($result);
}
/**
* Apply virtual coupon to cart
*
* @param string $coupon_code Coupon code
* @param float $discount_amount Discount amount
* @param int $points Points being redeemed
* @return bool Success status
*/
private static function apply_virtual_coupon($coupon_code, $discount_amount, $points) {
if (!function_exists('WC')) {
return false;
}
/** @disregard */
$cart = WC()->cart;
if (!$cart) {
return false;
}
// Store coupon data for virtual coupon
set_transient('loyalty_coupon_' . $coupon_code, [
'amount' => $discount_amount,
'points' => $points,
'user_id' => get_current_user_id(),
'created' => time()
], self::EXPIRATION_TIME);
// Apply the coupon
$result = $cart->apply_coupon($coupon_code);
if ($result) {
// Force cart calculation
$cart->calculate_totals();
}
return $result;
}
/**
* Remove existing points coupon from cart
*/
private static function remove_existing_points_coupon() {
if (!function_exists('WC')) {
return;
}
/** @disregard */
$cart = WC()->cart;
if (!$cart) {
return;
}
$applied_coupons = $cart->get_applied_coupons();
foreach ($applied_coupons as $coupon_code) {
if (strpos($coupon_code, self::COUPON_PREFIX) === 0) {
$cart->remove_coupon($coupon_code);
// Clean up transient
delete_transient('loyalty_coupon_' . $coupon_code);
}
}
}
/**
* Get virtual coupon data for WooCommerce
*
* @param array|false $coupon_data Existing coupon data
* @param string $coupon_code Coupon code
* @return array|false Coupon data or false
*/
public static function get_virtual_coupon_data($coupon_data, $coupon_code) {
// Check if this is our virtual coupon
if (strpos($coupon_code, self::COUPON_PREFIX) !== 0) {
return $coupon_data;
}
// Get coupon data from transient
$stored_data = get_transient('loyalty_coupon_' . $coupon_code);
if (!$stored_data) {
return false;
}
// Verify user
if ($stored_data['user_id'] !== get_current_user_id()) {
return false;
}
// Return virtual coupon configuration
return [
'id' => $coupon_code,
'amount' => $stored_data['amount'],
'individual_use' => false,
'product_ids' => [],
'exclude_product_ids' => [],
'usage_limit' => 1,
'usage_limit_per_user' => 0,
'limit_usage_to_x_items' => '',
'usage_count' => 0,
'expiry_date' => '',
'apply_before_tax' => 'yes',
'free_shipping' => false,
'product_categories' => [],
'exclude_product_categories' => [],
'exclude_sale_items' => false,
'minimum_amount' => '',
'maximum_amount' => '',
'customer_email' => '',
'discount_type' => 'fixed_cart',
'description' => WPGL_Points_Core::format_points($stored_data['points'])
];
}
/**
* Apply points discount via Store API (block checkout)
*/
public static function handle_apply_points_discount($points) {
return self::apply_points_discount_core($points, true);
}
/**
* Apply points discount via AJAX (standard checkout)
*/
public static function apply_points_discount() {
check_ajax_referer('loyalty_points_nonce', 'nonce');
$points = isset($_POST['points']) ? absint($_POST['points']) : 0;
return self::apply_points_discount_core($points, false);
}
/**
* Core method to remove points discount - used by both block and non-block
*
* @param bool $is_block Whether this is called from block checkout
* @return array|void Array on success for block, void for non-block
*/
private static function remove_points_discount_core($is_block = false) {
// Simply remove existing points coupon
self::remove_existing_points_coupon();
// Force cart recalculation
if (function_exists('WC')) {
/** @disregard */
$cart = WC()->cart;
if ($cart) {
$cart->calculate_totals();
}
}
return $is_block ? ['success' => true] : wp_send_json_success();
}
/**
* Remove points discount via Store API (block checkout)
*/
public static function handle_remove_points_discount() {
return self::remove_points_discount_core(true);
}
/**
* Remove points discount via AJAX (standard checkout)
*/
public static function remove_points_discount() {
check_ajax_referer('loyalty_points_nonce', 'nonce');
return self::remove_points_discount_core(false);
}
public static function check_points_applied()
{
check_ajax_referer('loyalty_points_nonce', 'nonce');
$applied_coupon = self::get_applied_points_coupon();
wp_send_json_success(['applied' => !empty($applied_coupon)]);
}
public static function clear_points_discount()
{
// Simply remove existing points coupon
self::remove_existing_points_coupon();
}
public static function process_points_deduction($order_id_or_order, $posted_data = null)
{
// Handle both order ID and order object
if (is_object($order_id_or_order) && is_a($order_id_or_order, 'WC_Order')) {
$order = $order_id_or_order;
$order_id = $order->get_id();
} else {
// We were passed just an order ID
$order_id = $order_id_or_order;
$order = wc_get_order($order_id);
}
// Ensure we have a valid order
if (!$order || !$order_id) {
WPGL_Logger::points("No valid order found for points deduction", [
'order_id' => $order_id,
'is_object' => is_object($order_id_or_order),
'is_wc_order' => is_object($order_id_or_order) && is_a($order_id_or_order, 'WC_Order')
]);
return;
}
// Get points data from applied coupon
$applied_coupon = self::get_applied_points_coupon();
if (empty($applied_coupon)) {
WPGL_Logger::points("No loyalty points coupon applied", [
'order_id' => $order_id
]);
return;
}
$settings = WPGL_Points_Core::get_settings();
$user_id = $applied_coupon['user_id'];
// Handle guest users if enabled
if (!$user_id && $settings['assignToGuests']) {
$billing_email = $order->get_billing_email();
$user = get_user_by('email', $billing_email);
if ($user) {
$user_id = $user->ID;
}
}
if (!$user_id) {
WPGL_Logger::points("No user ID found for points deduction", [
'assign_to_guests' => $settings['assignToGuests']
]);
return;
}
// Verify user still has enough points before processing
$current_points = WPGL_Database::get_user_points($user_id);
if ($current_points < $applied_coupon['points']) {
// Cancel order if points are no longer available
$order->update_status('cancelled', __('Order cancelled - insufficient points', 'wpgens-points-and-rewards-program'));
WPGL_Logger::points("Order cancelled - insufficient points", [
'user_id' => $user_id,
'current_points' => $current_points,
'points_needed' => $applied_coupon['points']
]);
return;
}
// Store points info in order meta first
$order->update_meta_data('_wpgens_loyalty_points_redeemed', $applied_coupon['points']);
$order->update_meta_data('_wpgens_loyalty_points_discount_amount', $applied_coupon['amount']);
$order->update_meta_data('_wpgens_loyalty_points_user_id', $user_id);
$order->save();
// Deduct points using the loyalty_update_points hook
do_action(
'wpgens_loyalty_update_points',
$user_id,
-$applied_coupon['points'],
WPGL_Points_Activity_Type::DEDUCT,
WPGL_Points_Source_Type::ORDER_DISCOUNT,
$order_id
);
// Clean up coupon data
delete_transient('loyalty_coupon_' . $applied_coupon['coupon_code']);
}
/**
* Display points that will be earned for current order
*/
public static function display_points_to_be_earned()
{
if (!function_exists('WC')) return;
$branding = WPGL_Points_Core::get_branding();
// Tip: This settings field is for both cart and checkout page even tho its named cart
if ($branding['showPointsEarningInCart'] !== true) return;
// Check if user is logged in or if guest points are enabled
$settings = WPGL_Points_Core::get_settings();
if (!is_user_logged_in() && !$settings['assignToGuests']) return;
$total_points = self::calculate_cart_points_earning();
if ($total_points > 0) {
echo '<div class="wpgens-points-earning-notice wpgens-points-earning-notice-fragment">';
echo '<div class="wpgens-points-earning-notice-inner">';
echo '<div class="wpgens-points-earning-notice-icon">';
echo '<svg width="32" height="32" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.40585 5.78711C8.23261 5.4368 8.13525 5.04227 8.13525 4.625C8.13525 3.17525 9.31051 2 10.7603 2C11.4948 2 12.1588 2.30168 12.6353 2.78788C13.1117 2.30168 13.7757 2 14.5103 2C15.96 2 17.1353 3.17525 17.1353 4.625C17.1353 5.04227 17.0379 5.4368 16.8647 5.78711H19.1433C20.386 5.78711 21.3933 6.79447 21.3933 8.03711V8.50001C21.3933 9.31501 20.96 10.0288 20.3112 10.4236V19C20.3112 20.2426 19.3038 21.25 18.0612 21.25H7.2096C5.96696 21.25 4.9596 20.2426 4.9596 19V10.4236C4.31077 10.0288 3.87744 9.31501 3.87744 8.50001V8.03711C3.87744 6.79447 4.8848 5.78711 6.12744 5.78711H8.40585ZM14.5103 3.5C13.8889 3.5 13.3853 4.00368 13.3853 4.625V5.75H14.5103C15.1316 5.75 15.6353 5.24632 15.6353 4.625C15.6353 4.00368 15.1316 3.5 14.5103 3.5ZM11.8853 5.75V4.625C11.8853 4.00368 11.3816 3.5 10.7603 3.5C10.1389 3.5 9.63525 4.00368 9.63525 4.625C9.63525 5.24632 10.1389 5.75 10.7603 5.75H11.8853ZM18.8112 10.75H6.4596V19C6.4596 19.4142 6.79538 19.75 7.2096 19.75H18.0612C18.4754 19.75 18.8112 19.4142 18.8112 19V10.75ZM19.1433 9.25001C19.5576 9.25001 19.8933 8.91422 19.8933 8.50001V8.03711C19.8933 7.6229 19.5576 7.28711 19.1433 7.28711H6.12744C5.71323 7.28711 5.37744 7.6229 5.37744 8.03711V8.50001C5.37744 8.91422 5.71323 9.25001 6.12744 9.25001H19.1433Z" fill="#323544"/>
</svg>';
echo '</div>';
echo '<div class="wpgens-points-earning-notice-content">';
echo '<h3 class="wpgens-points-earning-notice-title">' . sprintf(
/* translators: %s: Points amount */
esc_html__('Complete this purchase to earn up to %s', 'wpgens-points-and-rewards-program'),
wp_kses_post(WPGL_Points_Core::format_points($total_points))
) . '</h3>';
echo '<div class="wpgens-points-earning-notice-subtitle">' .
sprintf(
/* translators: %s: Points label */
esc_html__('Use your %s to redeem a discount on your next order.', 'wpgens-points-and-rewards-program'),
esc_html(WPGL_Points_Core::get_points_label(2))
) .
'</div>';
echo '</div>';
echo '</div>';
echo '</div>';
}
}
// Add this method to support AJAX fragment updates
public static function update_points_earning_fragment($fragments) {
ob_start();
self::display_points_to_be_earned();
$fragments['.wpgens-points-earning-notice-fragment'] = ob_get_clean();
return $fragments;
}
/**
* Calculate points data for display and localization
*
* @return array|false Array with points data or false if calculation not possible
*/
private static function calculate_points_data()
{
// Check if WooCommerce functions are available
if (!function_exists('WC') || !function_exists('wc_get_page_permalink') || !function_exists('is_cart') || !function_exists('is_checkout')) {
return false;
}
// Only proceed if we're on cart or checkout page
if (!is_cart() && !is_checkout()) return false;
// Get settings and conversion rate
$settings = WPGL_Points_Core::get_settings();
$conversion_rate = $settings['conversionRate'];
$branding = WPGL_Points_Core::get_branding();
// Get user points if logged in
$user_id = get_current_user_id();
$points = $user_id ? WPGL_Database::get_user_points($user_id) : 0;
// Get my account page URL
$myaccount_url = wc_get_page_permalink('myaccount');
// For non-logged in users, return basic data
if (!$user_id) {
return [
'points' => 0,
'usable_points' => 0,
'potential_discount' => 0,
'cart_total' => 0,
'conversion_rate' => $conversion_rate,
'points_applied' => false,
'applied_points' => 0,
'applied_amount' => 0,
'settings' => [
'branding' => $branding,
'conversionRate' => $conversion_rate,
'conversionRateEnabled' => $settings['conversionRateEnabled'] ?? false
],
'points_label' => WPGL_Points_Core::get_points_label(0),
'signInPageUrl' => !empty($settings['signInPageUrl']) ? $settings['signInPageUrl'] : $myaccount_url,
/* translators: %1$s: Points amount, %2$s: Monetary value */
'pointsAvailableText' => esc_html__('You have %1$s points available (worth %2$s)', 'wpgens-points-and-rewards-program')
];
}
// For logged in users, check minimum points requirement
if ($points < $conversion_rate['minPoints']) return false;
// Calculate usable points
$usable_points = min($points, $conversion_rate['maxPoints']); // Limit to max points
$potential_discount = ($usable_points / $conversion_rate['points']) * $conversion_rate['value'];
// Get cart total for max points calculation
if (!function_exists('WC') || !function_exists('wc_price')) return false;
/** @disregard */
$cart = WC()->cart;
if (!$cart) return false;
// Get cart total (coupons handle tax automatically, so we use the simple total)
$cart_total = $cart->get_total('edit');
// Cap discount at cart total
if ($potential_discount > $cart_total) {
$potential_discount = $cart_total;
$usable_points = ceil(($potential_discount * $conversion_rate['points']) / $conversion_rate['value']);
}
// Check if points are already applied
$applied_coupon = self::get_applied_points_coupon();
$points_applied = !empty($applied_coupon);
return [
'points' => $points,
'usable_points' => $usable_points,
'potential_discount' => $potential_discount,
'cart_total' => $cart_total,
'conversion_rate' => $conversion_rate,
'points_applied' => $points_applied,
'applied_points' => $points_applied ? $applied_coupon['points'] : 0,
'applied_amount' => $points_applied ? $applied_coupon['amount'] : 0,
'settings' => [
'branding' => $branding,
'conversionRate' => $conversion_rate,
'conversionRateEnabled' => $settings['conversionRateEnabled'] ?? false
],
'points_label' => WPGL_Points_Core::get_points_label($points),
'signInPageUrl' => !empty($settings['signInPageUrl']) ? $settings['signInPageUrl'] : $myaccount_url,
/* translators: %1$s: Points amount, %2$s: Monetary value */
'pointsAvailableText' => esc_html__('You have %1$s points available (worth %2$s)', 'wpgens-points-and-rewards-program')
];
}
/**
* Localize points data for frontend scripts
*/
public static function localize_points_data()
{
$data = self::calculate_points_data();
if (!$data) return;
// Calculate points to be earned using our helper
$points_to_earn = self::calculate_cart_points_earning();
$localized_data = [
'conversionRate' => $data['conversion_rate'],
'cartTotal' => $data['cart_total'],
'userPoints' => $data['points'],
'usablePoints' => $data['usable_points'],
'potentialDiscount' => $data['potential_discount'],
'minPointsMessage' => sprintf(
/* translators: %s: Points label */
__('Minimum %s required', 'wpgens-points-and-rewards-program'),
WPGL_Points_Core::get_points_label($data['conversion_rate']['minPoints'])
),
'applyingText' => __('Applying...', 'wpgens-points-and-rewards-program'),
'applyDiscountText' => __('Apply Discount', 'wpgens-points-and-rewards-program'),
'errorText' => __('Error applying discount', 'wpgens-points-and-rewards-program'),
'nonce' => wp_create_nonce('loyalty_points_nonce'),
'primaryColor' => $data['settings']['branding']['primary_color'] ?? '#be8b3b',
'hidePointsBoxZeroBalance' => isset($data['settings']['branding']['hidePointsBoxZeroBalance']) ? (bool)$data['settings']['branding']['hidePointsBoxZeroBalance'] : false,
'conversionRateEnabled' => $data['settings']['conversionRateEnabled'] ?? false,
'autoOpenPointsRedemption' => isset($data['settings']['branding']['autoOpenPointsRedemption']) ? (bool)$data['settings']['branding']['autoOpenPointsRedemption'] : false,
// Guest-related data
'isLoggedIn' => is_user_logged_in(),
'guestMessage' => __('Log in to view your points balance and discover rewards available for redemption.', 'wpgens-points-and-rewards-program'),
'loginText' => __('Log In', 'wpgens-points-and-rewards-program'),
'signInPageUrl' => $data['signInPageUrl'],
// New strings for the checkout block
'applyPointsTitle' => sprintf(
/* translators: %s: Points label */
__('Apply %s discount?', 'wpgens-points-and-rewards-program'),
WPGL_Points_Core::get_points_label($data['usable_points'])
),
'pointsAppliedText' => sprintf(
/* translators: %s: Points label */
__('%s have been applied.', 'wpgens-points-and-rewards-program'),
WPGL_Points_Core::get_points_label($data['applied_points'])
),
'removeDiscountText' => __('Remove Discount', 'wpgens-points-and-rewards-program'),
'pointsAvailableText' => sprintf(
/* translators: %1$s: Points amount, %2$s: Monetary value */
esc_html__('You have %1$s points available (worth %2$s)', 'wpgens-points-and-rewards-program'),
WPGL_Points_Core::format_points($data['points']),
WPGL_Points_Core::calculate_points_value($data['points'])
),
'enterPointsText' => sprintf(
/* translators: %s: Points label */
__('Enter the amount of %s you want to apply as discount for this order.', 'wpgens-points-and-rewards-program'),
WPGL_Points_Core::get_points_label($data['usable_points'])
),
'enterAmountText' => __('Enter amount', 'wpgens-points-and-rewards-program'),
'applyText' => __('Apply', 'wpgens-points-and-rewards-program'),
// Add points_applied flag
'points_applied' => $data['points_applied'],
'applied_points' => $data['applied_points'],
'applied_amount' => $data['applied_amount'],
// Add points earning data
'pointsToEarn' => $points_to_earn > 0 ? WPGL_Points_Core::format_points($points_to_earn) : '',
'pointsToEarnText' => sprintf(
/* translators: %s: Points amount */
esc_html__('Complete this purchase to earn up to %s', 'wpgens-points-and-rewards-program'),
'{points}'
),
'pointsEarningSubtitle' => sprintf(
/* translators: %s: Points label */
esc_html__('Use your %s to redeem a discount on your next order.', 'wpgens-points-and-rewards-program'),
esc_html(WPGL_Points_Core::get_points_label(2))
)
];
if ($data['points_applied']) {
$localized_data['applyingText'] = __('Removing...', 'wpgens-points-and-rewards-program');
$localized_data['applyDiscountText'] = __('Remove Discount', 'wpgens-points-and-rewards-program');
$localized_data['errorText'] = __('Error removing discount', 'wpgens-points-and-rewards-program');
}
wp_localize_script('wpgens-loyalty-frontend', 'loyaltyPointsConversion', $localized_data);
}
/**
* Generate points conversion HTML
*
* @return string|false HTML content or false if no data
*/
private static function generate_points_conversion_html()
{
$data = self::calculate_points_data();
if (!$data) return false;
// Get branding settings for auto-open
$branding = WPGL_Points_Core::get_branding();
$auto_open = isset($branding['autoOpenPointsRedemption']) ? $branding['autoOpenPointsRedemption'] : false;
// Build the HTML content
ob_start();
// If points are applied, display the applied points notice
if ($data['points_applied']) {
echo '<div class="wpgens-accordion wpgens-points-redemption-block">';
echo '<div class="wpgens-accordion wpgens-points-checkout-ui show">';
echo '<h3>';
echo '<span class="wpgens-accordion-title">' . sprintf(
/* translators: %s: Points amount */
esc_html__('Apply %s discount?', 'wpgens-points-and-rewards-program'),
esc_html(WPGL_Points_Core::get_points_label($data['usable_points']))
) . '</span>';
echo '<span class="caret"><svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1.41 0.590088L6 5.17009L10.59 0.590088L12 2.00009L6 8.00009L0 2.00009L1.41 0.590088Z" fill="#212121"/></svg></span>';
echo '</h3>';
echo '<div class="wpgens-accordion-inner" style="display: block;">';
echo '<div class="wpgens-accordion-content">';
echo '<div class="wpgens-points-user-balance">';
echo '<div><strong>' . esc_html($data['applied_points']) . '</strong> ' . sprintf(
/* translators: %s: Points amount */
esc_html__('%s have been applied.', 'wpgens-points-and-rewards-program'),
esc_html(WPGL_Points_Core::get_points_label($data['applied_points']))
) . '</div>';
echo '</div>';
echo '<button type="button" class="wpgens-loyalty-button wpgens-loyalty-remove-discount-btn">' .
esc_html__('Remove Discount', 'wpgens-points-and-rewards-program') .
'</button>';
echo '</div>';
echo '</div>';
echo '</div>';
echo '</div>';
} else {
// Create the notice HTML with accordion structure
echo '<div class="wpgens-accordion wpgens-points-redemption-block">';
echo '<div class="wpgens-accordion wpgens-points-checkout-ui' . ($auto_open ? ' show' : '') . '">';
echo '<h3>';
echo '<span class="wpgens-accordion-title">' . sprintf(
/* translators: %s: Points amount */
esc_html__('Apply %s discount?', 'wpgens-points-and-rewards-program'),
WPGL_Points_Core::get_points_label($data['usable_points'])
) . '</span>';
echo '<span class="caret"><svg width="12" height="8" viewBox="0 0 12 8" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1.41 0.590088L6 5.17009L10.59 0.590088L12 2.00009L6 8.00009L0 2.00009L1.41 0.590088Z" fill="#212121"/></svg></span>';
echo '</h3>';
echo '<div class="wpgens-accordion-inner" style="display: ' . ($auto_open ? 'block' : 'none') . ';">';
echo '<div class="wpgens-accordion-content">';
// Check if user is logged in
if (!is_user_logged_in()) {
echo '<p class="wpgens-points-guest-message">' .
esc_html__('Log in to view your points balance and discover rewards available for redemption.', 'wpgens-points-and-rewards-program') .
'</p>';
echo '<div class="wpgens-points-login-button-wrapper">';
echo '<a href="' . esc_url($data['signInPageUrl']) . '" class="wpgens-loyalty-button wpgens-loyalty-login-button">' .
esc_html__('Log In', 'wpgens-points-and-rewards-program') .
'</a>';
echo '</div>';
} else {
// Points balance
echo '<div class="wpgens-points-user-balance">';
echo '<div>' . sprintf(
/* translators: %1$s: Points amount, %2$s: Monetary value */
esc_html__('You have %1$s points available (worth %2$s)', 'wpgens-points-and-rewards-program'),
'<strong>' . wp_kses_post(WPGL_Points_Core::format_points($data['points'])) . '</strong>',
wp_kses_post(WPGL_Points_Core::calculate_points_value($data['points']))
) . '</div>';
echo '</div>';
// Instructions
echo '<p class="wpgens-points-instructions">' . sprintf(
/* translators: %s: Points amount */
esc_html__('Enter the amount of %s you want to apply as discount for this order.', 'wpgens-points-and-rewards-program'),
WPGL_Points_Core::get_points_label($data['usable_points'])
) . '</p>';
// Input field and button
echo '<div id="wpgens_redeem_points" class="wpgens-redeem-points-form-field wpgens-checkout-form-button-field">';
echo '<p class="form-row form-row-first wpgens-form-control-wrapper wpgens-col-left-half">';
echo '<label htmlFor="points_amount"></label>';
echo '<input type="text" class="input-text" placeholder="' . esc_attr__('Enter amount', 'wpgens-points-and-rewards-program') . '" value="' . esc_attr($data['usable_points']) . '" id="loyalty-points-to-redeem" class="wpgens-loyalty-points-input" />';
echo '</p>';
echo '<p class="form-row form-row-last wpgens-col-left-half wpgens_points_btn_wrap">';
echo '<label class="wpgens-form-control-label"></label>';
echo '<button type="button" class="wpgens-loyalty-button wpgens-loyalty-apply-discount-btn">' . esc_html__('Apply', 'wpgens-points-and-rewards-program') . '</button>';
echo '</p>';
echo '</div>';
}
echo '</div>';
echo '</div>';
echo '</div>';
echo '</div>';
}
return ob_get_clean();
}
/**
* Display points conversion notice in cart and checkout
*/
public static function display_points_conversion_notice()
{
$html = self::generate_points_conversion_html();
if ($html) {
// We are using wp_localize_script to add the nonce, so we don't need to escape the output
// @phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $html;
}
}
public static function update_points_conversion_fragments($fragments)
{
$html = self::generate_points_conversion_html();
if ($html) {
// Add to fragments with a selector that targets our points redemption block
$fragments['.wpgens-points-redemption-block'] = $html;
}
return $fragments;
}
/**
* Calculate points for a single cart/order item
* Simplified since we no longer need complex tax handling
*
* @param WC_Product $product Product object
* @param int $quantity Item quantity
* @return int|false Points earned for this item or false if not eligible
*/
private static function calculate_item_points($product, $quantity)
{
if (!$product) return false;
// Get points using product-level settings or fallback to general settings
$product_points = WPGL_Points_Products::get_product_points($product);
if ($product_points !== false) {
return $product_points * $quantity;
}
return false;
}
/**
* Calculate total points that will be earned for current cart
*
* @return int Total points to be earned
*/
private static function calculate_cart_points_earning()
{
if (!function_exists('WC')) return 0;
/** @disregard */
$cart = WC()->cart;
if (!$cart) return 0;
$earning_actions = WPGL_Points_Core::get_earning_actions();
$order_action = array_filter($earning_actions, function ($action) {
return $action['type'] === WPGL_Points_Source_Type::PLACE_ORDER && $action['enabled'];
});
if (empty($order_action)) return 0;
$total_points = 0;
foreach ($cart->get_cart() as $cart_item) {
$product = $cart_item['data'];
if (!$product) continue;
$item_points = self::calculate_item_points($product, $cart_item['quantity']);
if ($item_points !== false) {
$total_points += $item_points;
}
}
// Apply discount ratio to total points
$cart_total = $cart->get_total('edit');
$cart_subtotal = $cart->get_subtotal() + $cart->get_subtotal_tax();
if ($cart_subtotal > 0) {
// Exclude shipping from discount ratio calculation
$cart_total_without_shipping = $cart_total - $cart->get_shipping_total() - $cart->get_shipping_tax();
$discount_ratio = $cart_total_without_shipping / $cart_subtotal;
WPGL_Logger::points("Cart discount ratio calculation", [
'cart_total' => $cart_total,
'cart_total_without_shipping' => $cart_total_without_shipping,
'cart_subtotal' => $cart_subtotal,
'discount_ratio' => $discount_ratio,
'points_before' => $total_points
]);
$total_points = round($total_points * $discount_ratio);
}
// Store calculated points in session for consistency with order creation
if (function_exists('WC') && WC()->session) {
WC()->session->set('wpgens_calculated_points', $total_points);
}
return $total_points;
}
/**
* Calculate and store points when an order is created
* Moved from class-points-hooks.php for consistency
*
* @param int $order_id WooCommerce order ID
*/
public static function calculate_order_points($order_id)
{
// Debug logging
WPGL_Logger::order("Starting points calculation for new order", [
'order_id' => $order_id
]);
$order = wc_get_order($order_id);
// Get order action from earning actions
$earning_actions = WPGL_Points_Core::get_earning_actions();
$order_action = array_filter($earning_actions, function ($action) {
return $action['type'] === WPGL_Points_Source_Type::PLACE_ORDER && $action['enabled'];
});
if (empty($order_action)) {
WPGL_Logger::order("No enabled PLACE_ORDER action found in earning actions");
return;
}
$action = reset($order_action);
$points_rate = $action['points']; // Points per currency unit
// Try to get calculated points from session first (single source of truth)
$total_points = 0;
if (function_exists('WC') && WC()->session) {
$stored_points = WC()->session->get('wpgens_calculated_points');
if ($stored_points !== null) {
$total_points = (int) $stored_points;
WPGL_Logger::points("Using stored points from cart calculation", [
'stored_points' => $total_points
]);
// Clear the stored points
WC()->session->__unset('wpgens_calculated_points');
}
}
// If we have points (either from session or fallback), store item-level points for consistency
if ($total_points > 0) {
foreach ($order->get_items() as $item) {
$product = $item->get_product();
$item_points = self::calculate_item_points($product, $item->get_quantity());
if ($item_points !== false) {
// Store points earned for this specific item
$item->update_meta_data('_wpgens_loyalty_points_earned', $item_points);
$item->update_meta_data('_wpgens_loyalty_points_rate', $points_rate);
$item->save();
}
}
}
// Store total points in order meta
if ($total_points > 0) {
$order->update_meta_data('_wpgens_loyalty_points_amount', $total_points);
$order->update_meta_data('_wpgens_loyalty_points_rate', $points_rate);
$order->save();
WPGL_Logger::order("Successfully stored calculated points", [
'order_id' => $order_id,
'total_points' => $total_points,
'points_rate' => $points_rate
]);
} else {
WPGL_Logger::order("No points to store", [
'order_id' => $order_id
]);
}
}
/**
* Customize coupon display for standard checkout
*/
public static function customize_coupon_label($label, $coupon)
{
if (strpos($coupon->get_code(), self::COUPON_PREFIX) === 0) {
// Get points data from transient
$coupon_data = get_transient('loyalty_coupon_' . $coupon->get_code());
if ($coupon_data && isset($coupon_data['points'])) {
return '🎁 ' . WPGL_Points_Core::format_points($coupon_data['points']);
}
return __('Points Applied', 'wpgens-points-and-rewards-program');
}
return $label;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment