From 53185fd49ed2fe314ff4a3e3d0dae5f3e18c6cca Mon Sep 17 00:00:00 2001 From: Malin Date: Fri, 23 Jan 2026 18:22:17 +0100 Subject: [PATCH] Implement Google Price Insights with update functionality Major rewrite using correct Google Merchant API: - Use price_insights_product_view table (correct API endpoint) - Fetch suggested_price and predicted performance changes - Show predicted impact on impressions, clicks, conversions New features: - Individual "Update" button per product - Bulk update with checkbox selection - Pagination (50 products per page) - Sort by potential gain (highest first) Price handling: - Always use tax-inclusive prices for comparison with Google - Convert back to store format when saving (handles tax-exclusive stores) - Set as sale price when updating UI improvements: - Color-coded gain/loss values - Color-coded predicted changes - Summary stats showing products that can increase/decrease - Total potential gain calculation Co-Authored-By: Claude Opus 4.5 --- admin/class-informatiq-sp-admin.php | 341 ++++++++++------ assets/js/admin.js | 419 +++++++++++++------- includes/class-informatiq-sp-google-api.php | 108 ++--- 3 files changed, 546 insertions(+), 322 deletions(-) diff --git a/admin/class-informatiq-sp-admin.php b/admin/class-informatiq-sp-admin.php index 39684678..5ba71f45 100644 --- a/admin/class-informatiq-sp-admin.php +++ b/admin/class-informatiq-sp-admin.php @@ -52,6 +52,8 @@ class Informatiq_SP_Admin { add_action( 'wp_ajax_informatiq_sp_test_connection', array( $this, 'handle_test_connection' ) ); add_action( 'wp_ajax_informatiq_sp_revoke_auth', array( $this, 'handle_revoke_auth' ) ); add_action( 'wp_ajax_informatiq_sp_compare_products', array( $this, 'handle_compare_products' ) ); + add_action( 'wp_ajax_informatiq_sp_update_price', array( $this, 'handle_update_price' ) ); + add_action( 'wp_ajax_informatiq_sp_bulk_update_prices', array( $this, 'handle_bulk_update_prices' ) ); // Enqueue admin assets. add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); @@ -601,20 +603,42 @@ class Informatiq_SP_Admin {

@@ -830,40 +854,15 @@ class Informatiq_SP_Admin { $this->logger ); - // Get benchmark prices from Price Competitiveness Report. - $benchmark_data = $google_api->get_all_benchmark_prices(); - $this->logger->info( sprintf( 'Loaded %d benchmark prices from Google', count( $benchmark_data ) ) ); + // Get price insights from Google (suggested prices + predicted impact). + $price_insights = $google_api->get_price_insights(); + $this->logger->info( sprintf( 'Loaded %d price insights from Google', count( $price_insights ) ) ); - // Also get all products for matching purposes. - $google_products_raw = $google_api->get_all_products(); - $google_products = array(); - - foreach ( $google_products_raw as $gp ) { - // Index by offerId (top-level field). - if ( ! empty( $gp['offerId'] ) ) { - $google_products['offer_' . $gp['offerId']] = $gp; - } - - // Index by GTIN - check attributes.gtin which is an ARRAY. - if ( ! empty( $gp['attributes']['gtin'] ) && is_array( $gp['attributes']['gtin'] ) ) { - foreach ( $gp['attributes']['gtin'] as $gtin_value ) { - $google_products['gtin_' . $gtin_value] = $gp; - } - } - - // Also check attributes.gtins (alternative field name). - if ( ! empty( $gp['attributes']['gtins'] ) && is_array( $gp['attributes']['gtins'] ) ) { - foreach ( $gp['attributes']['gtins'] as $gtin_value ) { - $google_products['gtin_' . $gtin_value] = $gp; - } - } - } - - // Get WooCommerce in-stock products (limit to 50 for performance). + // Get all WooCommerce in-stock products (no limit - load all). $wc_products = wc_get_products( array( 'status' => 'publish', 'stock_status' => 'instock', - 'limit' => 50, + 'limit' => -1, 'return' => 'objects', 'type' => array( 'simple', 'variable' ), ) ); @@ -876,115 +875,66 @@ class Informatiq_SP_Admin { continue; } - // Get local price (sale price priority, then regular). - // Always calculate tax-inclusive price for comparison with Google (which is always tax-inclusive). + $product_id = $product->get_id(); + + // Get local price (tax-inclusive for comparison with Google). $sale_price = $product->get_sale_price(); $regular_price = $product->get_regular_price(); $base_price = ! empty( $sale_price ) ? $sale_price : $regular_price; $price_type = ! empty( $sale_price ) ? 'sale' : 'regular'; - // Get price including tax for fair comparison with Google. - $local_price = null; + $local_price_incl_tax = null; if ( $base_price ) { - $local_price = wc_get_price_including_tax( $product, array( 'price' => $base_price ) ); + $local_price_incl_tax = wc_get_price_including_tax( $product, array( 'price' => $base_price ) ); } - // Try to find matching Google product and benchmark. - $google_product = null; - $benchmark_info = null; - $match_type = ''; - $product_id = $product->get_id(); - - // Try offerId match (using WooCommerce product ID). - if ( isset( $google_products['offer_' . $product_id] ) ) { - $google_product = $google_products['offer_' . $product_id]; - $match_type = 'product_id'; - } - // Try offerId match with SKU. - elseif ( isset( $google_products['offer_' . $sku] ) ) { - $google_product = $google_products['offer_' . $sku]; - $match_type = 'offerId'; - } - // Try GTIN match (when SKU is a barcode). - elseif ( isset( $google_products['gtin_' . $sku] ) ) { - $google_product = $google_products['gtin_' . $sku]; - $match_type = 'gtin'; + // Try to find matching price insight by WooCommerce product ID (which is Google's offerId). + $insight = null; + if ( isset( $price_insights[ 'offer_' . $product_id ] ) ) { + $insight = $price_insights[ 'offer_' . $product_id ]; } - // Get benchmark data (competitor price). - if ( isset( $benchmark_data['offer_' . $product_id] ) ) { - $benchmark_info = $benchmark_data['offer_' . $product_id]; - } elseif ( isset( $benchmark_data['offer_' . $sku] ) ) { - $benchmark_info = $benchmark_data['offer_' . $sku]; - } elseif ( isset( $benchmark_data['gtin_' . $sku] ) ) { - $benchmark_info = $benchmark_data['gtin_' . $sku]; - } + // Calculate potential gain and determine if update is beneficial. + $suggested_price = $insight['suggested_price'] ?? null; + $potential_gain = null; + $should_update = false; - // Get Google's own price from product data. - $google_own_price = null; - if ( $google_product ) { - if ( isset( $google_product['attributes']['price']['amountMicros'] ) ) { - $google_own_price = (float) $google_product['attributes']['price']['amountMicros'] / 1000000; - } - } - - // Get competitor benchmark price. - $benchmark_price = $benchmark_info['benchmark_price'] ?? null; - - // Calculate recommended price (slightly below competitor). - $recommended_price = null; - $potential_gain = null; - $price_status = 'unknown'; - - if ( $benchmark_price && $local_price ) { - // Recommended: 20 cents below competitor (or 0.5% whichever is less). - $offset = min( 0.20, $benchmark_price * 0.005 ); - $recommended_price = round( $benchmark_price - $offset, 2 ); - - // Calculate potential gain per unit. - $potential_gain = round( $recommended_price - $local_price, 2 ); - - // Determine status. - if ( $local_price < $benchmark_price * 0.98 ) { - $price_status = 'cheaper'; // More than 2% cheaper - could increase. - } elseif ( $local_price <= $benchmark_price ) { - $price_status = 'competitive'; // Within 2% below - good. - } else { - $price_status = 'expensive'; // Above competitor. - } + if ( $suggested_price && $local_price_incl_tax ) { + $potential_gain = round( $suggested_price - $local_price_incl_tax, 2 ); + // Suggest update if Google recommends a different price. + $should_update = abs( $potential_gain ) > 0.05; } $comparison[] = array( - 'id' => $product_id, - 'name' => $product->get_name(), - 'sku' => $sku, - 'local_price' => $local_price ? (float) $local_price : null, - 'price_type' => $price_type, - 'google_own_price' => $google_own_price, - 'benchmark_price' => $benchmark_price, - 'recommended_price' => $recommended_price, - 'potential_gain' => $potential_gain, - 'price_status' => $price_status, - 'match_type' => $match_type, - 'found' => ! empty( $google_product ), - 'has_benchmark' => ! empty( $benchmark_price ), - 'google_offer' => $google_product ? ( $google_product['offerId'] ?? '' ) : '', + 'id' => $product_id, + 'name' => $product->get_name(), + 'sku' => $sku, + 'local_price' => $local_price_incl_tax ? round( $local_price_incl_tax, 2 ) : null, + 'price_type' => $price_type, + 'google_price' => $insight['google_price'] ?? null, + 'suggested_price' => $suggested_price, + 'potential_gain' => $potential_gain, + 'predicted_impressions_change' => $insight['predicted_impressions_change'] ?? null, + 'predicted_clicks_change' => $insight['predicted_clicks_change'] ?? null, + 'predicted_conversions_change' => $insight['predicted_conversions_change'] ?? null, + 'has_insight' => ! empty( $insight ), + 'should_update' => $should_update, ); } - // Get sample of indexed keys for debugging. - $sample_keys = array_slice( array_keys( $google_products ), 0, 10 ); + // Sort by potential gain (highest first). + usort( $comparison, function( $a, $b ) { + $gain_a = $a['potential_gain'] ?? 0; + $gain_b = $b['potential_gain'] ?? 0; + return $gain_b <=> $gain_a; + } ); wp_send_json_success( array( - 'products' => $comparison, - 'google_count' => count( $google_products_raw ), - 'wc_count' => count( $wc_products ), - 'currency' => get_woocommerce_currency_symbol(), - 'debug' => array( - 'sample_google_keys' => $sample_keys, - 'sample_google_product' => ! empty( $google_products_raw[0] ) ? $google_products_raw[0] : null, - 'index_count' => count( $google_products ), - ), + 'products' => $comparison, + 'insights_count' => count( $price_insights ), + 'wc_count' => count( $wc_products ), + 'currency' => get_woocommerce_currency_symbol(), + 'currency_code' => get_woocommerce_currency(), ) ); } catch ( Exception $e ) { @@ -992,6 +942,147 @@ class Informatiq_SP_Admin { } } + /** + * Handle individual price update AJAX request. + */ + public function handle_update_price() { + check_ajax_referer( 'informatiq_sp_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'informatiq-smart-pricing' ) ) ); + } + + $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0; + $new_price = isset( $_POST['new_price'] ) ? floatval( $_POST['new_price'] ) : 0; + + if ( ! $product_id || $new_price <= 0 ) { + wp_send_json_error( array( 'message' => __( 'Invalid product ID or price.', 'informatiq-smart-pricing' ) ) ); + } + + $product = wc_get_product( $product_id ); + if ( ! $product ) { + wp_send_json_error( array( 'message' => __( 'Product not found.', 'informatiq-smart-pricing' ) ) ); + } + + // Convert tax-inclusive price to the format WooCommerce expects. + $price_to_set = $new_price; + if ( ! wc_prices_include_tax() ) { + // Store prices exclude tax, so we need to reverse-calculate. + $tax_rates = WC_Tax::get_rates( $product->get_tax_class() ); + if ( ! empty( $tax_rates ) ) { + $tax_rate = 0; + foreach ( $tax_rates as $rate ) { + $tax_rate += (float) $rate['rate']; + } + $price_to_set = $new_price / ( 1 + ( $tax_rate / 100 ) ); + } + } + + $price_to_set = round( $price_to_set, wc_get_price_decimals() ); + + // Store old price for logging. + $old_price = $product->get_sale_price() ?: $product->get_regular_price(); + + // Set as sale price (since we're adjusting to be competitive). + $product->set_sale_price( $price_to_set ); + $product->save(); + + // Log the update. + $this->logger->log_price_update( + $product_id, + $product->get_name(), + $old_price, + $price_to_set, + $new_price, + 'Manual update to Google suggested price' + ); + + wp_send_json_success( array( + 'message' => sprintf( + __( 'Price updated for %s: %s', 'informatiq-smart-pricing' ), + $product->get_name(), + wc_price( $price_to_set ) + ), + 'new_price' => $price_to_set, + ) ); + } + + /** + * Handle bulk price update AJAX request. + */ + public function handle_bulk_update_prices() { + check_ajax_referer( 'informatiq_sp_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'informatiq-smart-pricing' ) ) ); + } + + $updates = isset( $_POST['updates'] ) ? $_POST['updates'] : array(); + + if ( empty( $updates ) || ! is_array( $updates ) ) { + wp_send_json_error( array( 'message' => __( 'No updates provided.', 'informatiq-smart-pricing' ) ) ); + } + + $updated = 0; + $errors = 0; + + foreach ( $updates as $update ) { + $product_id = absint( $update['product_id'] ?? 0 ); + $new_price = floatval( $update['new_price'] ?? 0 ); + + if ( ! $product_id || $new_price <= 0 ) { + $errors++; + continue; + } + + $product = wc_get_product( $product_id ); + if ( ! $product ) { + $errors++; + continue; + } + + // Convert tax-inclusive price to the format WooCommerce expects. + $price_to_set = $new_price; + if ( ! wc_prices_include_tax() ) { + $tax_rates = WC_Tax::get_rates( $product->get_tax_class() ); + if ( ! empty( $tax_rates ) ) { + $tax_rate = 0; + foreach ( $tax_rates as $rate ) { + $tax_rate += (float) $rate['rate']; + } + $price_to_set = $new_price / ( 1 + ( $tax_rate / 100 ) ); + } + } + + $price_to_set = round( $price_to_set, wc_get_price_decimals() ); + $old_price = $product->get_sale_price() ?: $product->get_regular_price(); + + $product->set_sale_price( $price_to_set ); + $product->save(); + + $this->logger->log_price_update( + $product_id, + $product->get_name(), + $old_price, + $price_to_set, + $new_price, + 'Bulk update to Google suggested price' + ); + + $updated++; + } + + wp_send_json_success( array( + 'message' => sprintf( + __( 'Bulk update complete: %d updated, %d errors.', 'informatiq-smart-pricing' ), + $updated, + $errors + ), + 'updated' => $updated, + 'errors' => $errors, + ) ); + } + /** * Enqueue admin assets. * diff --git a/assets/js/admin.js b/assets/js/admin.js index 1bed88b5..fe8d5e33 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -2,20 +2,14 @@ * Admin JavaScript for Informatiq Smart Pricing */ -console.log('Informatiq Smart Pricing: Script loaded'); - (function($) { 'use strict'; - console.log('Informatiq Smart Pricing: IIFE executed, jQuery available:', typeof $ !== 'undefined'); - var InformatiqSP = { /** * Initialize */ init: function() { - console.log('Informatiq Smart Pricing: init() called'); - console.log('informatiqSP object:', typeof informatiqSP !== 'undefined' ? informatiqSP : 'NOT DEFINED'); this.bindEvents(); }, @@ -23,17 +17,10 @@ console.log('Informatiq Smart Pricing: Script loaded'); * Bind event handlers */ bindEvents: function() { - console.log('Informatiq Smart Pricing: bindEvents() called'); - - var $compareBtn = $('#informatiq-sp-compare-products'); - console.log('Compare products button found:', $compareBtn.length > 0); - $('#informatiq-sp-manual-sync').on('click', this.handleManualSync); $('#informatiq-sp-test-connection').on('click', this.handleTestConnection); $('#informatiq-sp-revoke-auth').on('click', this.handleRevokeAuth); - $compareBtn.on('click', this.handleCompareProducts); - - console.log('Informatiq Smart Pricing: All event handlers bound'); + $('#informatiq-sp-compare-products').on('click', this.handleCompareProducts); }, /** @@ -195,167 +182,50 @@ console.log('Informatiq Smart Pricing: Script loaded'); }); }, + // Store comparison data for pagination and bulk updates. + comparisonData: null, + currentPage: 1, + perPage: 50, + /** * Handle compare products button click */ handleCompareProducts: function(e) { e.preventDefault(); - console.log('=== COMPARE PRODUCTS CLICKED ==='); - + var self = InformatiqSP; var $button = $(this); var $spinner = $button.next('.spinner'); var $results = $('#informatiq-sp-comparison-results'); - var $tbody = $('#informatiq-sp-comparison-tbody'); var $status = $('#informatiq-sp-sync-status'); - console.log('Button:', $button.length); - console.log('Spinner:', $spinner.length); - console.log('Results container:', $results.length); - console.log('informatiqSP:', informatiqSP); - // Show loading status $status .removeClass('notice-success notice-error') .addClass('notice-info') - .html('

Loading product comparison... This may take a moment.

') + .html('

Loading price insights from Google... This may take a moment.

') .show(); - // Disable button and show spinner $button.prop('disabled', true); $spinner.addClass('is-active'); - console.log('Making AJAX request to:', informatiqSP.ajaxUrl); - console.log('With nonce:', informatiqSP.nonce); - - // Make AJAX request $.ajax({ url: informatiqSP.ajaxUrl, type: 'POST', - timeout: 120000, // 2 minute timeout + timeout: 180000, // 3 minute timeout data: { action: 'informatiq_sp_compare_products', nonce: informatiqSP.nonce }, - beforeSend: function(xhr) { - console.log('AJAX beforeSend - request starting'); - }, success: function(response) { - console.log('AJAX success callback'); - console.log('AJAX response:', response); - - // Log debug info if available. - if (response.data && response.data.debug) { - console.log('=== DEBUG INFO ==='); - console.log('Sample Google product:', response.data.debug.sample_google_product); - console.log('Sample indexed keys:', response.data.debug.sample_google_keys); - console.log('Total indexed entries:', response.data.debug.index_count); - console.log('=================='); - } - + console.log('Price insights response:', response); $status.hide(); if (response.success) { - var data = response.data; - var html = ''; - - // Stats counters. - var stats = { - total: 0, - withBenchmark: 0, - cheaper: 0, - competitive: 0, - expensive: 0, - totalPotentialGain: 0 - }; - - if (data.products.length === 0) { - html = 'No in-stock products with SKU found.'; - } else { - $.each(data.products, function(i, product) { - stats.total++; - - var localPrice = product.local_price ? data.currency + parseFloat(product.local_price).toFixed(2) : '-'; - var benchmarkPrice = product.benchmark_price ? data.currency + parseFloat(product.benchmark_price).toFixed(2) : '-'; - var recommendedPrice = product.recommended_price ? data.currency + parseFloat(product.recommended_price).toFixed(2) : '-'; - var potentialGain = '-'; - var priceLabel = product.price_type === 'sale' ? ' (sale)' : ''; - - // Status and colors. - var statusText = '-'; - var statusStyle = ''; - var localPriceStyle = ''; - var gainStyle = ''; - - if (product.has_benchmark) { - stats.withBenchmark++; - - if (product.price_status === 'cheaper') { - stats.cheaper++; - statusText = 'Cheaper'; - statusStyle = 'color: #00a32a; font-weight: bold;'; - localPriceStyle = 'color: #00a32a;'; - } else if (product.price_status === 'competitive') { - stats.competitive++; - statusText = 'Competitive'; - statusStyle = 'color: #2271b1; font-weight: bold;'; - localPriceStyle = 'color: #2271b1;'; - } else if (product.price_status === 'expensive') { - stats.expensive++; - statusText = 'Expensive'; - statusStyle = 'color: #d63638; font-weight: bold;'; - localPriceStyle = 'color: #d63638;'; - } - - if (product.potential_gain !== null) { - var gain = parseFloat(product.potential_gain); - stats.totalPotentialGain += gain; - if (gain > 0) { - potentialGain = '+' + data.currency + gain.toFixed(2); - gainStyle = 'color: #00a32a; font-weight: bold;'; - } else if (gain < 0) { - potentialGain = '-' + data.currency + Math.abs(gain).toFixed(2); - gainStyle = 'color: #d63638;'; - } else { - potentialGain = data.currency + '0.00'; - } - } - } else if (product.found) { - statusText = 'No benchmark'; - statusStyle = 'color: #dba617;'; - } else { - statusText = 'Not in Google'; - statusStyle = 'color: #888;'; - } - - html += ''; - html += '' + product.name + ''; - html += '' + localPrice + priceLabel + ''; - html += '' + benchmarkPrice + ''; - html += '' + recommendedPrice + ''; - html += '' + potentialGain + ''; - html += '' + statusText + ''; - html += ''; - }); - } - - $tbody.html(html); + self.comparisonData = response.data; + self.currentPage = 1; + self.renderComparison(); $results.show(); - - // Show summary with statistics. - var $summary = $('#informatiq-sp-comparison-summary'); - var summaryHtml = 'Summary: '; - summaryHtml += stats.withBenchmark + ' of ' + stats.total + ' products have competitor data. '; - summaryHtml += '' + stats.cheaper + ' cheaper, '; - summaryHtml += '' + stats.competitive + ' competitive, '; - summaryHtml += '' + stats.expensive + ' expensive. '; - if (stats.totalPotentialGain > 0) { - summaryHtml += '
Total potential gain if optimized: +' + data.currency + stats.totalPotentialGain.toFixed(2) + ' per sale'; - } else if (stats.totalPotentialGain < 0) { - summaryHtml += '
Current margin vs recommended: ' + data.currency + stats.totalPotentialGain.toFixed(2) + ''; - } - $summary.html(summaryHtml).show(); - } else { $status .removeClass('notice-info notice-success') @@ -365,20 +235,275 @@ console.log('Informatiq Smart Pricing: Script loaded'); } }, error: function(jqXHR, textStatus, errorThrown) { - console.error('AJAX error:', textStatus, errorThrown, jqXHR.responseText); + console.error('AJAX error:', textStatus, errorThrown); $status .removeClass('notice-info notice-success') .addClass('notice-error') - .html('

Error: ' + errorThrown + ' - ' + textStatus + '

') + .html('

Error: ' + errorThrown + '

') .show(); }, complete: function() { - console.log('AJAX request complete'); $button.prop('disabled', false); $spinner.removeClass('is-active'); } }); - } + }, + + /** + * Render comparison table with pagination + */ + renderComparison: function() { + var self = this; + var data = this.comparisonData; + var $tbody = $('#informatiq-sp-comparison-tbody'); + var $summary = $('#informatiq-sp-comparison-summary'); + var $pagination = $('#informatiq-sp-pagination'); + + if (!data || !data.products) return; + + var products = data.products; + var totalPages = Math.ceil(products.length / this.perPage); + var start = (this.currentPage - 1) * this.perPage; + var end = start + this.perPage; + var pageProducts = products.slice(start, end); + + // Stats + var stats = { + total: products.length, + withInsight: 0, + canIncrease: 0, + shouldDecrease: 0, + totalPotentialGain: 0 + }; + + products.forEach(function(p) { + if (p.has_insight) { + stats.withInsight++; + if (p.potential_gain > 0) { + stats.canIncrease++; + stats.totalPotentialGain += p.potential_gain; + } else if (p.potential_gain < 0) { + stats.shouldDecrease++; + } + } + }); + + // Render summary + var summaryHtml = 'Summary: ' + stats.withInsight + ' of ' + stats.total + ' products have Google price insights. '; + summaryHtml += '' + stats.canIncrease + ' can increase price, '; + summaryHtml += '' + stats.shouldDecrease + ' should decrease price. '; + if (stats.totalPotentialGain > 0) { + summaryHtml += '
Total potential gain: +' + data.currency + stats.totalPotentialGain.toFixed(2) + ' per sale cycle'; + } + $summary.html(summaryHtml); + + // Render table rows + var html = ''; + if (pageProducts.length === 0) { + html = 'No products found.'; + } else { + pageProducts.forEach(function(product) { + var localPrice = product.local_price ? data.currency + parseFloat(product.local_price).toFixed(2) : '-'; + var suggestedPrice = product.suggested_price ? data.currency + parseFloat(product.suggested_price).toFixed(2) : '-'; + var priceLabel = product.price_type === 'sale' ? ' (sale)' : ''; + + // Gain/loss styling + var gainHtml = '-'; + var gainStyle = ''; + if (product.potential_gain !== null) { + var gain = parseFloat(product.potential_gain); + if (gain > 0) { + gainHtml = '+' + data.currency + gain.toFixed(2); + gainStyle = 'color: #00a32a; font-weight: bold;'; + } else if (gain < 0) { + gainHtml = '-' + data.currency + Math.abs(gain).toFixed(2); + gainStyle = 'color: #d63638;'; + } else { + gainHtml = data.currency + '0.00'; + } + } + + // Predicted changes + var imprChange = product.predicted_impressions_change !== null + ? (product.predicted_impressions_change > 0 ? '+' : '') + product.predicted_impressions_change.toFixed(1) + '%' + : '-'; + var clickChange = product.predicted_clicks_change !== null + ? (product.predicted_clicks_change > 0 ? '+' : '') + product.predicted_clicks_change.toFixed(1) + '%' + : '-'; + var convChange = product.predicted_conversions_change !== null + ? (product.predicted_conversions_change > 0 ? '+' : '') + product.predicted_conversions_change.toFixed(1) + '%' + : '-'; + + var imprStyle = product.predicted_impressions_change > 0 ? 'color:#00a32a;' : (product.predicted_impressions_change < 0 ? 'color:#d63638;' : ''); + var clickStyle = product.predicted_clicks_change > 0 ? 'color:#00a32a;' : (product.predicted_clicks_change < 0 ? 'color:#d63638;' : ''); + var convStyle = product.predicted_conversions_change > 0 ? 'color:#00a32a;' : (product.predicted_conversions_change < 0 ? 'color:#d63638;' : ''); + + // Checkbox and action button + var checkbox = product.has_insight && product.should_update + ? '' + : ''; + var actionBtn = product.has_insight && product.should_update + ? '' + : '-'; + + html += ''; + html += '' + checkbox + ''; + html += '' + self.truncate(product.name, 40) + ''; + html += '' + localPrice + priceLabel + ''; + html += '' + suggestedPrice + ''; + html += '' + gainHtml + ''; + html += '' + imprChange + ''; + html += '' + clickChange + ''; + html += '' + convChange + ''; + html += '' + actionBtn + ''; + html += ''; + }); + } + + $tbody.html(html); + + // Render pagination + if (totalPages > 1) { + var paginationHtml = 'Page ' + this.currentPage + ' of ' + totalPages + ' '; + if (this.currentPage > 1) { + paginationHtml += ' '; + } + if (this.currentPage < totalPages) { + paginationHtml += ''; + } + $pagination.html(paginationHtml).show(); + } else { + $pagination.hide(); + } + + // Bind events for this page + self.bindComparisonEvents(); + }, + + /** + * Truncate text + */ + truncate: function(str, len) { + if (!str) return ''; + return str.length > len ? str.substring(0, len) + '...' : str; + }, + + /** + * Bind comparison table events + */ + bindComparisonEvents: function() { + var self = this; + + // Single update buttons + $('.informatiq-sp-update-single').off('click').on('click', function() { + var $btn = $(this); + var productId = $btn.data('product-id'); + var newPrice = $btn.data('new-price'); + + $btn.prop('disabled', true).text('Updating...'); + + $.ajax({ + url: informatiqSP.ajaxUrl, + type: 'POST', + data: { + action: 'informatiq_sp_update_price', + nonce: informatiqSP.nonce, + product_id: productId, + new_price: newPrice + }, + success: function(response) { + if (response.success) { + $btn.text('Done!').addClass('button-primary'); + $btn.closest('tr').css('background-color', '#d4edda'); + } else { + alert('Error: ' + response.data.message); + $btn.prop('disabled', false).text('Update'); + } + }, + error: function() { + alert('Request failed'); + $btn.prop('disabled', false).text('Update'); + } + }); + }); + + // Pagination + $('.informatiq-sp-page').off('click').on('click', function() { + self.currentPage = $(this).data('page'); + self.renderComparison(); + $('html, body').animate({ scrollTop: $('#informatiq-sp-comparison-results').offset().top - 50 }, 200); + }); + + // Select all checkboxes + $('#informatiq-sp-select-all, #informatiq-sp-select-all-header').off('change').on('change', function() { + var checked = $(this).prop('checked'); + $('.informatiq-sp-select-product').prop('checked', checked); + $('#informatiq-sp-select-all, #informatiq-sp-select-all-header').prop('checked', checked); + self.updateBulkButton(); + }); + + // Individual checkbox + $('.informatiq-sp-select-product').off('change').on('change', function() { + self.updateBulkButton(); + }); + + // Bulk update button + $('#informatiq-sp-bulk-update').off('click').on('click', function() { + self.handleBulkUpdate(); + }); + }, + + /** + * Update bulk button state + */ + updateBulkButton: function() { + var count = $('.informatiq-sp-select-product:checked').length; + $('#informatiq-sp-bulk-update').prop('disabled', count === 0).text('Bulk Update Selected (' + count + ')'); + }, + + /** + * Handle bulk update + */ + handleBulkUpdate: function() { + var updates = []; + $('.informatiq-sp-select-product:checked').each(function() { + updates.push({ + product_id: $(this).data('product-id'), + new_price: $(this).data('new-price') + }); + }); + + if (updates.length === 0) return; + + if (!confirm('Update prices for ' + updates.length + ' products?')) return; + + var $btn = $('#informatiq-sp-bulk-update'); + $btn.prop('disabled', true).text('Updating...'); + + $.ajax({ + url: informatiqSP.ajaxUrl, + type: 'POST', + data: { + action: 'informatiq_sp_bulk_update_prices', + nonce: informatiqSP.nonce, + updates: updates + }, + success: function(response) { + if (response.success) { + alert(response.data.message); + location.reload(); + } else { + alert('Error: ' + response.data.message); + $btn.prop('disabled', false).text('Bulk Update Selected'); + } + }, + error: function() { + alert('Request failed'); + $btn.prop('disabled', false).text('Bulk Update Selected'); + } + }); + }, + }; // Initialize when document is ready diff --git a/includes/class-informatiq-sp-google-api.php b/includes/class-informatiq-sp-google-api.php index 7701074f..a39f7043 100644 --- a/includes/class-informatiq-sp-google-api.php +++ b/includes/class-informatiq-sp-google-api.php @@ -457,27 +457,32 @@ class Informatiq_SP_Google_API { } /** - * Get all benchmark prices from Price Competitiveness Report. + * Get price insights from Google's price_insights_product_view. * - * @return array Associative array of offer_id => benchmark_price. + * Returns suggested prices and predicted performance impact. + * + * @return array Associative array of offer_id => price insight data. */ - public function get_all_benchmark_prices() { + public function get_price_insights() { try { $endpoint = "/reports/v1beta/accounts/{$this->merchant_id}/reports:search"; - $all_benchmarks = array(); + $all_insights = array(); $page_token = null; do { - // New Merchant API uses snake_case table names. - // Fields from product are prefixed with product_view. $query = array( 'query' => "SELECT - product_view.offer_id, - product_view.gtin, - product_view.title, - benchmark_price - FROM price_competitiveness_product_view", + id, + offer_id, + title, + brand, + price, + suggested_price, + predicted_impressions_change_fraction, + predicted_clicks_change_fraction, + predicted_conversions_change_fraction + FROM price_insights_product_view", ); if ( $page_token ) { @@ -486,53 +491,56 @@ class Informatiq_SP_Google_API { $response = $this->api_request( 'POST', $endpoint, $query ); - $this->logger->info( 'Benchmark API response keys: ' . wp_json_encode( array_keys( $response ) ) ); - if ( ! empty( $response['results'][0] ) ) { - $this->logger->info( 'Sample benchmark result: ' . wp_json_encode( $response['results'][0] ) ); + // Log first result for debugging. + if ( empty( $all_insights ) && ! empty( $response['results'][0] ) ) { + $this->logger->info( 'Sample price insight: ' . wp_json_encode( $response['results'][0] ) ); } if ( ! empty( $response['results'] ) ) { foreach ( $response['results'] as $result ) { - // Extract data - response nests product fields under productView. - $product_view = $result['productView'] ?? array(); - $offer_id = $product_view['offerId'] ?? ( $product_view['offer_id'] ?? null ); - $gtin = $product_view['gtin'] ?? null; - $title = $product_view['title'] ?? ''; + $insight = $result['priceInsightsProductView'] ?? $result; - $benchmark_price = null; - // Try different possible response structures for benchmark. - if ( isset( $result['benchmarkPrice']['amountMicros'] ) ) { - $benchmark_price = (float) $result['benchmarkPrice']['amountMicros'] / 1000000; - } elseif ( isset( $result['benchmark_price']['amountMicros'] ) ) { - $benchmark_price = (float) $result['benchmark_price']['amountMicros'] / 1000000; - } elseif ( isset( $result['priceBenchmark']['priceBenchmarkValue']['amountMicros'] ) ) { - $benchmark_price = (float) $result['priceBenchmark']['priceBenchmarkValue']['amountMicros'] / 1000000; + $offer_id = $insight['offerId'] ?? ( $insight['offer_id'] ?? null ); + $product_id = $insight['id'] ?? null; + + // Extract offer_id from product_id if needed (format: lang~country~offerId). + if ( ! $offer_id && $product_id ) { + $parts = explode( '~', $product_id ); + if ( count( $parts ) >= 3 ) { + $offer_id = $parts[ count( $parts ) - 1 ]; + } } - $own_price = null; - if ( isset( $product_view['price']['amountMicros'] ) ) { - $own_price = (float) $product_view['price']['amountMicros'] / 1000000; - } elseif ( isset( $result['price']['amountMicros'] ) ) { - $own_price = (float) $result['price']['amountMicros'] / 1000000; + $current_price = null; + if ( isset( $insight['price']['amountMicros'] ) ) { + $current_price = (float) $insight['price']['amountMicros'] / 1000000; } - if ( $offer_id && $benchmark_price ) { - $all_benchmarks[ 'offer_' . $offer_id ] = array( - 'benchmark_price' => $benchmark_price, - 'own_price' => $own_price, - 'title' => $title, - 'gtin' => $gtin, + $suggested_price = null; + if ( isset( $insight['suggestedPrice']['amountMicros'] ) ) { + $suggested_price = (float) $insight['suggestedPrice']['amountMicros'] / 1000000; + } + + if ( $offer_id ) { + $data = array( + 'offer_id' => $offer_id, + 'product_id' => $product_id, + 'title' => $insight['title'] ?? '', + 'brand' => $insight['brand'] ?? '', + 'google_price' => $current_price, + 'suggested_price' => $suggested_price, + 'predicted_impressions_change' => isset( $insight['predictedImpressionsChangeFraction'] ) + ? (float) $insight['predictedImpressionsChangeFraction'] * 100 + : null, + 'predicted_clicks_change' => isset( $insight['predictedClicksChangeFraction'] ) + ? (float) $insight['predictedClicksChangeFraction'] * 100 + : null, + 'predicted_conversions_change' => isset( $insight['predictedConversionsChangeFraction'] ) + ? (float) $insight['predictedConversionsChangeFraction'] * 100 + : null, ); - // Also index by GTIN if available. - if ( $gtin ) { - $all_benchmarks[ 'gtin_' . $gtin ] = array( - 'benchmark_price' => $benchmark_price, - 'own_price' => $own_price, - 'title' => $title, - 'offer_id' => $offer_id, - ); - } + $all_insights[ 'offer_' . $offer_id ] = $data; } } } @@ -541,12 +549,12 @@ class Informatiq_SP_Google_API { } while ( $page_token ); - $this->logger->info( sprintf( 'Fetched benchmark prices for %d products', count( $all_benchmarks ) ) ); + $this->logger->info( sprintf( 'Fetched price insights for %d products', count( $all_insights ) ) ); - return $all_benchmarks; + return $all_insights; } catch ( Exception $e ) { - $this->logger->error( 'Error fetching benchmark prices: ' . $e->getMessage() ); + $this->logger->error( 'Error fetching price insights: ' . $e->getMessage() ); return array(); } }