PluginProbe
WiserReview Product Reviews for WooCommerce / 3.7
WiserReview Product Reviews for WooCommerce v3.7
3.7 3.6 3.5 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0 2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 3.0 All 29 releases
wiser-review / wiser-review.php

wiser-review.php in WiserReview Product Reviews for WooCommerce 3.7, at wiser-review.php

3,665 lines 127.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Name: WiserReview Product Reviews for WooCommerce
4 * Plugin URI: https://wiserreview.com
5 * Description: Collect, manage, and display powerful product reviews and testimonials in WooCommerce using the WiserReview platform. Boost trust and conversion with automated review collection.
6 * Version: 3.7
7 * Author: Wiser Notify
8 * Author URI: https://wiserreview.com
9 * Requires Plugins: woocommerce
10 * Tested up to: 6.9
11 * Requires at least: 5.6
12 * Requires PHP: 7.4
13 * Text Domain: wiser-review
14 * License: GPLv2 or later
15 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
16 *
17 * @package wiser-review
18 * */
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23 define( 'WISERRW_PLUGIN_FILE', __FILE__ );
24 define( 'WISERRW_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
25 define( 'WISERRW_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
26 define( 'WISERRW_PLUGIN_VERSION', '3.7' );
27 define( 'WISERRW_API_HOST', 'https://api.wiserreview.com/api/woocommerce/' );
28 define( 'WISERRW_RS_HOST', 'https://rs.wiserreview.com' );
29
30 if ( ! function_exists( 'wiserrw_scripts' ) ) {
31 /**
32 * Plugin styles and scripts.
33 */
34 function wiserrw_scripts( $hook ) {
35 $allowed_screens = array(
36 'toplevel_page_wiser-review'
37 );
38 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
39 $screen_id = $screen ? $screen->id : $hook;
40 if ( ! in_array( $screen_id, $allowed_screens, true ) ) {
41 return;
42 }
43
44 wp_enqueue_style( 'wiserrw-admin-styles', plugins_url( '/assets/css/wiserrw-admin.css', WISERRW_PLUGIN_FILE ), array(), WISERRW_PLUGIN_VERSION );
45 wp_register_script( 'wiserrw-admin-script', plugins_url( '/assets/js/wiserw-js.js', WISERRW_PLUGIN_FILE ), array(), WISERRW_PLUGIN_VERSION );
46 wp_localize_script(
47 'wiserrw-admin-script',
48 'wiserrw_ajax_var',
49 array(
50 'url' => admin_url( 'admin-ajax.php' ),
51 'wiserrw_security' => wp_create_nonce( 'wiserrw_export_orders_nonce' ),
52 'action' => 'wiserrw_save_settings',
53 'nonce' => wp_create_nonce( 'wiserrw_save_settings' )
54 )
55 );
56 }
57 }
58 add_action( 'admin_enqueue_scripts', 'wiserrw_scripts' );
59
60 function wiserrw_ajax_save_settings() {
61 // Verify nonce for security
62 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'wiserrw_save_settings' ) ) {
63 wp_send_json_error( array(
64 'message' => 'Security check failed.',
65 ), 403 );
66 }
67
68 // Check user capabilities
69 if ( ! current_user_can( 'manage_options' ) ) {
70 wp_send_json_error( array(
71 'message' => 'Unauthorized access.',
72 ), 403 );
73 }
74
75 $wiserrw_data = isset( $_POST['wiserrw_data'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['wiserrw_data'] ) ) : array();
76
77 // Sanitize review form URL
78 if ( ! empty( $wiserrw_data['wiserrw_review_form_url'] ) ) {
79 $wiserrw_data['wiserrw_review_form_url'] = rtrim( strtok( trim( $wiserrw_data['wiserrw_review_form_url'] ), '?' ), '/' );
80 }
81
82 // Sanitize Google Merchant ID: digits only, 8–15 chars. Anything else
83 // blanks the field AND force-disables the GCR toggle so a malformed ID
84 // can't leave the feature "on" with a broken value.
85 if ( isset( $wiserrw_data['wiserrw_gcr_merchant_id'] ) ) {
86 $gcr_mid_clean = preg_replace( '/\D+/', '', (string) $wiserrw_data['wiserrw_gcr_merchant_id'] );
87 if ( '' !== $gcr_mid_clean && preg_match( '/^\d{8,15}$/', $gcr_mid_clean ) ) {
88 $wiserrw_data['wiserrw_gcr_merchant_id'] = $gcr_mid_clean;
89 } else {
90 $wiserrw_data['wiserrw_gcr_merchant_id'] = '';
91 unset( $wiserrw_data['wiserrw_gcr_enabled'] );
92 }
93 }
94 // Force review page toggle off if URL is invalid
95 $review_url = $wiserrw_data['wiserrw_review_form_url'] ?? '';
96 if ( empty( $review_url ) || strpos( $review_url, 'https://rating.wiserreview.com/' ) !== 0 ) {
97 unset( $wiserrw_data['wiserrw_review_page_enabled'] );
98 $wiserrw_data['wiserrw_review_form_url'] = '';
99 }
100
101 // Check if review page toggle changed BEFORE saving
102 $old_settings = get_option( 'wiserrw_api_settings', array() );
103 $old_review_page = $old_settings['wiserrw_review_page_enabled'] ?? '';
104 $new_review_page = $wiserrw_data['wiserrw_review_page_enabled'] ?? '';
105 $review_page_changed = ( $old_review_page !== $new_review_page );
106
107 update_option( 'wiserrw_api_settings', $wiserrw_data );
108
109 // Flush rewrite rules AFTER saving so the new setting is active
110 if ( $review_page_changed ) {
111 wiserrw_review_page_add_endpoint();
112 flush_rewrite_rules();
113 }
114
115 wp_send_json_success( array(
116 'message' => 'Settings saved.',
117 'saved' => $wiserrw_data,
118 ) );
119 }
120 add_action( 'wp_ajax_wiserrw_save_settings', 'wiserrw_ajax_save_settings' );
121
122 if ( ! function_exists( 'wiserrw_api_settings' ) ) {
123 /**
124 * Setup API settings page in admin panel.
125 */
126 function wiserrw_api_settings() {
127 add_menu_page(
128 __( 'Wiser Review', 'wiser-review' ),
129 __( 'Wiser Review', 'wiser-review' ),
130 'manage_options',
131 'wiser-review',
132 'wiserrw_api_settings_callback',
133 plugins_url( '/assets/images/icon.svg', WISERRW_PLUGIN_FILE ),
134 );
135 }
136 }
137 add_action( 'admin_menu', 'wiserrw_api_settings' );
138
139 /**
140 * Callback for the plugin settings.
141 */
142 function wiserrw_api_settings_callback() {
143 $wiserrw_api_settings = get_option('wiserrw_api_settings', array());
144 if (!wp_style_is('woocommerce_admin_styles', 'registered')) {
145 return;
146 }
147 wp_enqueue_style('woocommerce_admin_styles');
148 wp_enqueue_script('wiserrw-admin-script');
149 include_once WISERRW_PLUGIN_DIR . '/views/wiserw-plugin-settings.php';
150 }
151
152 /**
153 * Recursive sanitation for an array
154 *
155 * @param array $array to sanitize.
156 *
157 * @return mixed
158 */
159 function wiserrw_recursive_sanitize_text_field( $array ) {
160 foreach ( $array as $key => &$value ) {
161 if ( is_array( $value ) ) {
162 $value = wiserrw_recursive_sanitize_text_field( $value );
163 } else {
164 $value = sanitize_text_field( $value );
165 }
166 }
167 return $array;
168 }
169
170 /**
171 * Validate API Key
172 *
173 * @param string $api_key API Key to validate.
174 *
175 * @return bool
176 */
177
178 function wiserrw_validate_api( $api_key ) {
179 if ( empty( $api_key ) ) {
180 return (object) array( 'status' => 'error', 'msg' => 'API key is required' );
181 }
182
183 $url = WISERRW_API_HOST . 'verify?key=' . urlencode( $api_key ) .
184 '&wiserrw_hook_url=' . urlencode( get_site_url() . '/wp-json/wiserrw/v1/reviews' ) .
185 '&ver=' . urlencode( WISERRW_PLUGIN_VERSION );
186
187 $args = array(
188 'method' => 'GET',
189 'timeout' => 20,
190 'headers' => array(
191 'x-wiserrw-api-key' => $api_key,
192 ),
193 );
194
195 $response = wp_remote_get( $url, $args );
196
197 if ( is_wp_error( $response ) ) {
198 error_log( 'API Verify Error: ' . $response->get_error_message() );
199 return (object) array( 'status' => 'error', 'msg' => 'Connection failed. Please try again.' );
200 }
201
202 $response_code = wp_remote_retrieve_response_code( $response );
203 if ( $response_code !== 200 ) {
204 return (object) array( 'status' => 'error', 'msg' => 'Invalid response from server. Please check your API key.' );
205 }
206
207 $body = json_decode( wp_remote_retrieve_body( $response ) );
208
209 if ( ! is_object( $body ) ) {
210 return (object) array( 'status' => 'error', 'msg' => 'Invalid response format. Please try again.' );
211 }
212
213 if ( isset( $body->data ) && isset( $body->data->wsid ) ) {
214 update_option( 'wiserrw_api_data', $body->data );
215 update_option( 'wiserrw_wsid', $body->data->wsid );
216 update_option( 'wiserrw_automation_id', $body->data->automation_id ?? '' );
217 }
218
219 return $body;
220 }
221
222
223 /**
224 * Show Product Reviews
225 */
226 function wiserrw_show_product_review() {
227 $wiserrw_api_settings = get_option( 'wiserrw_api_settings' );
228 if ( isset($wiserrw_api_settings['wiserrw_product_review_enabled']) && '1' === $wiserrw_api_settings['wiserrw_product_review_enabled'] ) {
229 $pid = wiserrw_get_current_product_id();
230 echo wiserrw_product_review( array( 'id' => $pid ) );
231 wiserrw_restore_product_globals();
232 }
233 }
234 add_action( 'woocommerce_after_single_product_summary', 'wiserrw_show_product_review', 14 );
235 // fix for the kadence theme
236 add_action( 'kadence_after_short_description_block', 'wiserrw_show_product_review' );
237
238
239 /**
240 * Show Product Reviews
241 */
242 function wiserrw_show_product_rating_on_collection() {
243 $wiserrw_api_settings = get_option( 'wiserrw_api_settings' );
244 if ( isset($wiserrw_api_settings['wiserrw_product_card_enabled']) && '1' === $wiserrw_api_settings['wiserrw_product_card_enabled'] ) {
245 $pid = wiserrw_get_current_product_id();
246 echo wiserrw_rating_count( array( 'id' => $pid ) );
247 wiserrw_restore_product_globals();
248 }
249 }
250 add_action( 'woocommerce_after_shop_loop_item_title', 'wiserrw_show_product_rating_on_collection', 5 );
251
252 /**
253 * Add review JS script to head
254 */
255 function wiserr_review_script() {
256 $settings = (array) get_option( 'wiserrw_api_settings', array() );
257 if ( empty( $settings['wiserrw_live_mode_checkbox'] ) || $settings['wiserrw_live_mode_checkbox'] !== '1' ) {
258 return;
259 }
260
261 $api_data = get_option( 'wiserrw_api_data', array() );
262
263 // Support object or array; avoid bool access warnings
264 $header_pixel = '';
265 if ( is_object( $api_data ) && ! empty( $api_data->header_pixel ) ) {
266 $header_pixel = $api_data->header_pixel;
267 } elseif ( is_array( $api_data ) && ! empty( $api_data['header_pixel'] ) ) {
268 $header_pixel = $api_data['header_pixel'];
269 } else {
270 return; // nothing to print
271 }
272
273 $allowed_tags = array(
274 'script' => array(
275 'src' => true,
276 'type' => true,
277 'async' => true,
278 'defer' => true,
279 ),
280 );
281
282 echo wp_kses( $header_pixel, $allowed_tags );
283 }
284 add_action( 'wp_footer', 'wiserr_review_script' );
285
286 /* ─────────────────────────────────────────────────────────────────────────
287 * Google Customer Reviews (Google Review Collector)
288 *
289 * Self-contained: the plugin renders Google's opt-in survey directly on the
290 * order-confirmation page using the Merchant ID + delivery-days configured on
291 * the settings page. No WiserReview backend / reviewPixel.js involvement.
292 * Three implementation methods: automatic (woocommerce_thankyou), shortcode,
293 * and a JavaScript API for custom/headless checkouts.
294 * ──────────────────────────────────────────────────────────────────────── */
295
296 /**
297 * Settings array for the Google Customer Reviews feature.
298 */
299 function wiserrw_gcr_settings() {
300 return (array) get_option( 'wiserrw_api_settings', array() );
301 }
302
303 /**
304 * Whether the feature is active. Gated on Live Mode + verified API key
305 * (same requirement as the other widgets), the enable toggle, and a Merchant ID.
306 */
307 function wiserrw_gcr_is_active() {
308 $s = wiserrw_gcr_settings();
309 if ( empty( $s['wiserrw_live_mode_checkbox'] ) ) {
310 return false;
311 }
312 if ( (int) get_option( 'wiserrw_api_verified', 0 ) !== 1 ) {
313 return false;
314 }
315 if ( empty( $s['wiserrw_gcr_enabled'] ) ) {
316 return false;
317 }
318 if ( empty( $s['wiserrw_gcr_merchant_id'] ) ) {
319 return false;
320 }
321 return true;
322 }
323
324 /**
325 * Estimated delivery days (defaults to 7).
326 */
327 function wiserrw_gcr_delivery_days() {
328 $s = wiserrw_gcr_settings();
329 $d = isset( $s['wiserrw_gcr_delivery_days'] ) ? (int) $s['wiserrw_gcr_delivery_days'] : 7;
330 return $d > 0 ? $d : 7;
331 }
332
333 /**
334 * estimated_delivery_date for Google (YYYY-MM-DD) = today + configured delivery days.
335 */
336 function wiserrw_gcr_estimated_delivery_date() {
337 return gmdate( 'Y-m-d', time() + ( wiserrw_gcr_delivery_days() * DAY_IN_SECONDS ) );
338 }
339
340 /**
341 * Build the Google survey payload from a WooCommerce order.
342 */
343 function wiserrw_gcr_build_payload( $order ) {
344 if ( ! $order instanceof WC_Order ) {
345 return null;
346 }
347 $s = wiserrw_gcr_settings();
348
349 $payload = array(
350 'merchant_id' => (string) ( $s['wiserrw_gcr_merchant_id'] ?? '' ),
351 'order_id' => (string) $order->get_order_number(),
352 'email' => $order->get_billing_email(),
353 // Google wants the delivery country; prefer shipping, fall back to billing.
354 'delivery_country' => $order->get_shipping_country() ?: $order->get_billing_country(),
355 'estimated_delivery_date' => wiserrw_gcr_estimated_delivery_date(),
356 );
357 return $payload;
358 }
359
360 /**
361 * Print Google's opt-in survey for a given order. Shared by the automatic and shortcode methods.
362 */
363 function wiserrw_gcr_render_optin( $order ) {
364 $payload = wiserrw_gcr_build_payload( $order );
365 if ( empty( $payload ) || empty( $payload['merchant_id'] ) ) {
366 return;
367 }
368 ?>
369 <script>
370 window.wiserRenderGcrOptIn = function () {
371 try {
372 window.gapi.load('surveyoptin', function () {
373 window.gapi.surveyoptin.render(<?php echo wp_json_encode( $payload ); ?>);
374 });
375 } catch (e) { console.log(e); }
376 };
377 </script>
378 <script src="https://apis.google.com/js/platform.js?onload=wiserRenderGcrOptIn" async defer></script>
379 <?php
380 }
381
382 /**
383 * Method 1 — Automatic: render on the standard WooCommerce thank-you page.
384 * Always attempts when GCR is active; no picker/method setting to gate on
385 * (matches how the plugin's other widgets auto-attach to WC hooks). If the
386 * theme doesn't fire woocommerce_thankyou (headless / heavily-customized
387 * checkout), this is a silent no-op and the merchant falls back to the
388 * shortcode or JS API — both are always available for that reason.
389 */
390 function wiserrw_gcr_thankyou( $order_id ) {
391 if ( ! wiserrw_gcr_is_active() ) {
392 return;
393 }
394 $order = wc_get_order( $order_id );
395 if ( $order ) {
396 wiserrw_gcr_render_optin( $order );
397 }
398 }
399 add_action( 'woocommerce_thankyou', 'wiserrw_gcr_thankyou', 20 );
400
401 /**
402 * Method 2 — Shortcode: [wiserrw_google_reviews] for custom / replaced thank-you pages.
403 * Resolves the order from the order-received page and verifies the order key.
404 * Always registered when GCR is active; merchants who need it on a custom
405 * page just paste it.
406 */
407 function wiserrw_gcr_shortcode() {
408 if ( ! wiserrw_gcr_is_active() ) {
409 return '';
410 }
411
412 $order_id = absint( get_query_var( 'order-received' ) );
413 if ( ! $order_id && isset( $_GET['order-received'] ) ) {
414 $order_id = absint( wp_unslash( $_GET['order-received'] ) );
415 }
416 if ( ! $order_id ) {
417 return '';
418 }
419
420 $order = wc_get_order( $order_id );
421 if ( ! $order ) {
422 return '';
423 }
424 // Verify the order key so the shortcode can't be used to render an arbitrary order.
425 $key = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : '';
426 if ( $key && ! hash_equals( $order->get_order_key(), $key ) ) {
427 return '';
428 }
429
430 ob_start();
431 wiserrw_gcr_render_optin( $order );
432 return ob_get_clean();
433 }
434 add_shortcode( 'wiserrw_google_reviews', 'wiserrw_gcr_shortcode' );
435
436
437 /**
438 * Show Product Rating Count.
439 */
440 function wiserrw_show_rating_count() {
441 $wiserrw_api_settings = get_option( 'wiserrw_api_settings' );
442 if ( isset($wiserrw_api_settings['wiserrw_star_rating_enabled']) && '1' === $wiserrw_api_settings['wiserrw_star_rating_enabled'] ) {
443 $pid = wiserrw_get_current_product_id();
444 echo wiserrw_rating_count( array( 'id' => $pid ) );
445 wiserrw_restore_product_globals();
446 }
447 }
448 add_action( 'woocommerce_single_product_summary', 'wiserrw_show_rating_count', 9 );
449 // fix for the kadence theme
450 if ( wp_get_theme()->get( 'Name' ) === 'Kadence' ) {
451 add_action( 'woocommerce_after_add_to_cart_button', 'wiserrw_show_rating_count' );
452 }
453
454
455 /**
456 * Show Review Nudges Section.
457 */
458 function wiserrw_show_review_nudges_section() {
459 $wiserrw_api_settings = get_option('wiserrw_api_settings', array());
460 if (isset($wiserrw_api_settings['wiserrw_review_nudges_enabled'])
461 && '1' === $wiserrw_api_settings['wiserrw_review_nudges_enabled']) {
462 $pid = wiserrw_get_current_product_id();
463 echo wiserrw_product_nudges( array( 'id' => $pid ) );
464 wiserrw_restore_product_globals();
465 }
466 }
467 add_action( 'woocommerce_after_add_to_cart_form', 'wiserrw_show_review_nudges_section' );
468
469 /**
470 * Get current product ID reliably across all themes including Divi 5,
471 * Bricks, Elementor Pro, Oxygen, and Goldish / other custom WC themes.
472 *
473 * The resolution uses $post (which the_post() / wp_reset_postdata()
474 * keep authoritative) as the discriminator between "main product page
475 * level" and "inside an inner loop iteration" — more reliable than WC's
476 * $GLOBALS['product'], which theme builders frequently leave stale.
477 *
478 * On a single product page:
479 * - Inside a related / up-sell / cross-sell / builder-element loop
480 * iteration → $post->ID is the loop item (the_post set it), so we
481 * return that. Fixes the "all loop items show main product rating"
482 * case.
483 * - At main page level, OR after a loop where wp_reset_postdata
484 * restored $post but WC's $GLOBALS['product'] is still pointing at
485 * the last looped product → $post->ID === queried_object_id, so we
486 * return queried_object_id (the main product). Fixes the "shortcode
487 * after up-sell shows wrong PID" case.
488 *
489 * On non-single-product pages (shop archive, category, custom page with
490 * a product loop) → fall back to $GLOBALS['product'] which is correct
491 * per-item inside the loop.
492 */
493 function wiserrw_get_current_product_id() {
494 if ( function_exists( 'is_product' ) && is_product() ) {
495 $qid = (int) get_queried_object_id();
496 $post_id = isset( $GLOBALS['post']->ID ) ? (int) $GLOBALS['post']->ID : 0;
497 // Inside an inner loop iteration → $post is the loop item
498 if ( $post_id && $qid && $post_id !== $qid ) {
499 return $post_id;
500 }
501 // Main page level (or after a loop that properly reset $post) → main product
502 if ( $qid ) {
503 return $qid;
504 }
505 }
506 if ( isset( $GLOBALS['product'] ) && $GLOBALS['product'] instanceof WC_Product ) {
507 return $GLOBALS['product']->get_id();
508 }
509 return get_the_ID() ?: 0;
510 }
511
512 /**
513 * Restore WC product globals after shortcode processing.
514 * Prevents corrupted product data for downstream WooCommerce hooks.
515 */
516 function wiserrw_restore_product_globals() {
517 if ( isset( $GLOBALS['post'] ) && function_exists( 'wc_setup_product_data' ) ) {
518 wc_setup_product_data( $GLOBALS['post'] );
519 }
520 }
521
522 /**
523 * Get product brand name from common sources used by WooCommerce merchants.
524 * Checks taxonomies (WooCommerce Brands, Perfect Brands), attributes, and custom meta.
525 *
526 * @param int|WC_Product $product_or_id Product ID or WC_Product object.
527 * @return string Brand name or empty string if not found.
528 */
529 function wiserrw_get_product_brand( $product_or_id ) {
530 $product = is_numeric( $product_or_id ) ? wc_get_product( $product_or_id ) : $product_or_id;
531 if ( ! $product ) {
532 return '';
533 }
534 $p_id = $product->get_id();
535
536 // 1. Native WooCommerce Brands (WC 9.0+) / WooCommerce Brands plugin
537 $terms = get_the_terms( $p_id, 'product_brand' );
538 if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
539 return $terms[0]->name;
540 }
541
542 // 2. Perfect Brands for WooCommerce plugin
543 $terms = get_the_terms( $p_id, 'pwb-brand' );
544 if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
545 return $terms[0]->name;
546 }
547
548 // 3. YITH WooCommerce Brands Add-On
549 $terms = get_the_terms( $p_id, 'yith_product_brand' );
550 if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
551 return $terms[0]->name;
552 }
553
554 // 4. Product attributes — check common brand attribute names in multiple languages
555 $brand_attr_names = array(
556 'Brand', 'brand', 'Manufacturer', // English
557 'Varumärke', // Swedish
558 'Marca', // Spanish / Italian / Portuguese
559 'Marke', // German
560 'Marque', // French
561 'Merk', // Dutch
562 'Márka', // Hungarian
563 'Marka', // Polish / Turkish
564 'Бренд', // Russian
565 'ブランド', // Japanese
566 '品牌', // Chinese
567 'العلا�
568 ة التجارية', // Arabic
569 );
570 foreach ( $brand_attr_names as $name ) {
571 $brand = $product->get_attribute( $name );
572 if ( ! empty( $brand ) ) {
573 return $brand;
574 }
575 }
576
577 // 5. Custom post meta (common conventions)
578 $brand = get_post_meta( $p_id, '_brand', true )
579 ?: get_post_meta( $p_id, '_manufacturer', true )
580 ?: get_post_meta( $p_id, 'hwp_product_brand', true );
581
582 return (string) $brand;
583 }
584
585 /**
586 * Normalize language code to 2-letter format, with WordPress site locale fallback.
587 * Use this to ensure $lang is never empty — falls back to site locale for
588 * single-language stores without WPML/Polylang.
589 *
590 * @param string $lang Language code detected from WPML/Polylang (may be empty).
591 * @return string 2-letter language code (e.g., "en", "de", "fr").
592 */
593 function wiserrw_normalize_lang( $lang = '' ) {
594 if ( empty( $lang ) ) {
595 // Fallback to WordPress site locale (e.g., "en_US" → "en")
596 $lang = get_locale();
597 }
598 return substr( (string) $lang, 0, 2 );
599 }
600
601 /**
602 * Shortcode for the Rating Count.
603 */
604 function wiserrw_rating_count( $atts ) {
605 $atts = shortcode_atts(
606 array(
607 'id' => wiserrw_get_current_product_id(),
608 ),
609 $atts,
610 'wiserrw_rating_count'
611 );
612
613 $product_id = intval( $atts['id'] );
614 if ( ! $product_id ) {
615 return '';
616 }
617
618 // Get SKU directly (faster than wc_get_product)
619 $sku = get_post_meta( $product_id, '_sku', true );
620
621 // Check settings
622 $wiserrw_api_settings = get_option( 'wiserrw_api_settings' );
623 if ( ! $wiserrw_api_settings || '1' !== ( $wiserrw_api_settings['wiserrw_live_mode_checkbox'] ?? '' ) ) {
624 return '';
625 }
626
627 // API HTML
628 $wiserrw_api_data = get_option( 'wiserrw_api_data' );
629 $product_rating_count = $wiserrw_api_data->product_rating_count ?? '';
630 $product_rating_count = preg_replace( '/data-pid=("|\')(.*?)\1/', 'data-pid="' . $product_id . '"', $product_rating_count );
631 $product_rating_count = preg_replace( '/data-skuid=("|\')(.*?)\1/', 'data-skuid="' . $sku . '"', $product_rating_count );
632
633 // --- Multilingual (cache per request) ---
634 static $lang_cache = array();
635 if ( isset( $lang_cache[$product_id] ) ) {
636 $opid_value = $lang_cache[$product_id]['opid'];
637 $current_lang = $lang_cache[$product_id]['lang'];
638 } else {
639 $original_pid = 0;
640 $current_lang = '';
641
642 // WPML
643 if ( has_filter( 'wpml_element_trid' ) ) {
644 $trid = apply_filters( 'wpml_element_trid', null, $product_id, 'post_product' );
645 $translations = $trid ? apply_filters( 'wpml_get_element_translations', null, $trid, 'post_product' ) : array();
646 $current_lang = apply_filters( 'wpml_current_language', null );
647
648 if ( $translations && is_array( $translations ) ) {
649 foreach ( $translations as $lang => $translation ) {
650 if ( ! empty( $translation->original ) ) {
651 $original_pid = (int) $translation->element_id;
652 }
653 }
654 }
655 }
656
657 // Polylang
658 if ( function_exists( 'pll_get_post' ) && function_exists( 'pll_current_language' ) ) {
659 $current_lang = pll_current_language();
660 $default_lang = pll_default_language();
661 if ( $current_lang && $default_lang && $current_lang !== $default_lang ) {
662 $original_pid = pll_get_post( $product_id, $default_lang );
663 }
664 }
665
666 // Fallback: current product if no original
667 $opid_value = ( $original_pid && $original_pid !== $product_id ) ? $original_pid : $product_id;
668 $current_lang = wiserrw_normalize_lang( $current_lang );
669
670 // Cache result
671 $lang_cache[$product_id] = array(
672 'opid' => $opid_value,
673 'lang' => $current_lang,
674 );
675 }
676
677 // Extract wrapper
678 $wiserrw_open_tag = '';
679 if ( preg_match( '/<div\b[^>]*>/i', $product_rating_count, $matches ) ) {
680 $wiserrw_open_tag = $matches[0];
681
682 // Ensure opid
683 if ( preg_match( '/data-opid=/', $wiserrw_open_tag ) ) {
684 $wiserrw_open_tag = preg_replace( '/data-opid=("|\')(.*?)\1/', 'data-opid="' . $opid_value . '"', $wiserrw_open_tag );
685 } else {
686 $wiserrw_open_tag = str_replace( '<div', '<div data-opid="' . esc_attr( $opid_value ) . '"', $wiserrw_open_tag );
687 }
688
689 // Ensure lang
690 if ( ! empty( $current_lang ) ) {
691 if ( preg_match( '/data-lang=/', $wiserrw_open_tag ) ) {
692 $wiserrw_open_tag = preg_replace( '/data-lang=("|\')(.*?)\1/', 'data-lang="' . $current_lang . '"', $wiserrw_open_tag );
693 } else {
694 $wiserrw_open_tag = str_replace( '<div', '<div data-lang="' . esc_attr( $current_lang ) . '"', $wiserrw_open_tag );
695 }
696 }
697 }
698
699 // Inner HTML: prefer post meta, patch if missing
700 $meta_html = get_post_meta( $product_id, 'wiserrw_star_rating_html', true );
701 // if ( $meta_html ) {
702 // Patch opid
703 if ( preg_match( '/data-opid=/', $meta_html ) ) {
704 $meta_html = preg_replace( '/data-opid=("|\')(.*?)\1/', 'data-opid="' . $opid_value . '"', $meta_html );
705 } else {
706 $meta_html = preg_replace( '/<div\b/i', '<div data-opid="' . esc_attr( $opid_value ) . '"', $meta_html, 1 );
707 }
708
709 // Patch lang
710 if ( ! empty( $current_lang ) ) {
711 if ( preg_match( '/data-lang=/', $meta_html ) ) {
712 $meta_html = preg_replace( '/data-lang=("|\')(.*?)\1/', 'data-lang="' . $current_lang . '"', $meta_html );
713 } else {
714 $meta_html = preg_replace( '/<div\b/i', '<div data-lang="' . esc_attr( $current_lang ) . '"', $meta_html, 1 );
715 }
716 }
717
718 $inner_html = $meta_html;
719
720 // Build output
721 $output = '';
722 if ( $wiserrw_open_tag ) {
723 $output .= $wiserrw_open_tag;
724 }
725 $output .= $inner_html;
726 $output .= '</div>';
727
728 return $output;
729 }
730 add_shortcode( 'wiserrw_rating_count', 'wiserrw_rating_count' );
731
732
733
734
735 /**
736 * Shortcode for the product.
737 */
738
739
740
741 function wiserrw_product_review( $atts ) {
742 $atts = shortcode_atts(
743 array(
744 'id' => wiserrw_get_current_product_id(),
745 ),
746 $atts,
747 'wiserrw_product_review'
748 );
749
750 $product_id = intval( $atts['id'] );
751 if ( ! $product_id ) {
752 return '';
753 }
754
755 $product = wc_get_product( $product_id );
756 if ( ! $product ) {
757 return '';
758 }
759
760 $wiserrw_api_settings = get_option( 'wiserrw_api_settings' );
761 if ( ! $wiserrw_api_settings || '1' !== ( $wiserrw_api_settings['wiserrw_live_mode_checkbox'] ?? '' ) ) {
762 return '';
763 }
764
765 $sku = $product->get_sku();
766 $data_login = is_user_logged_in() ? 'true' : 'false';
767 $wiserrw_api_data = get_option( 'wiserrw_api_data' );
768
769 // --- Multilingual (WPML / Polylang) ---
770 $original_pid = 0;
771 $current_lang = '';
772
773 // WPML
774 if ( has_filter( 'wpml_element_trid' ) ) {
775 $trid = apply_filters( 'wpml_element_trid', null, $product_id, 'post_product' );
776 $translations = $trid ? apply_filters( 'wpml_get_element_translations', null, $trid, 'post_product' ) : array();
777 $current_lang = apply_filters( 'wpml_current_language', null );
778
779 if ( $translations && is_array( $translations ) ) {
780 foreach ( $translations as $lang => $translation ) {
781 if ( ! empty( $translation->original ) ) {
782 $original_pid = (int) $translation->element_id;
783 }
784 }
785 }
786 }
787
788 // Polylang
789 if ( function_exists( 'pll_get_post' ) && function_exists( 'pll_current_language' ) ) {
790 $current_lang = pll_current_language();
791 $default_lang = pll_default_language();
792 if ( $current_lang && $default_lang && $current_lang !== $default_lang ) {
793 $original_pid = pll_get_post( $product_id, $default_lang );
794 }
795 }
796
797 // Fallback: use current product if no original found
798 $opid_value = ( $original_pid && $original_pid !== $product_id ) ? $original_pid : $product_id;
799 $current_lang = wiserrw_normalize_lang( $current_lang );
800
801 // --- Patch API HTML ---
802 $product_review_section = $wiserrw_api_data->product_review_section ?? '';
803
804 // --- Ensure data-lgin (NOT data-login — "lgin" is the correct attribute name used by the pixel JS) ---
805 if ( preg_match( '/data-lgin=/', $product_review_section ) ) {
806 $product_review_section = preg_replace( '/data-lgin=("|\')(.*?)\1/', 'data-lgin="' . $data_login . '"', $product_review_section );
807 } else {
808 $product_review_section = preg_replace( '/<div\b/i', '<div data-lgin="' . esc_attr( $data_login ) . '"', $product_review_section, 1 );
809 }
810
811 // --- Ensure pid ---
812 $product_review_section = preg_replace( '/data-pid=("|\')(.*?)\1/', 'data-pid="' . $product_id . '"', $product_review_section );
813
814 // --- Ensure skuid ---
815 $product_review_section = preg_replace( '/data-skuid=("|\')(.*?)\1/', 'data-skuid="' . $sku . '"', $product_review_section );
816
817 // --- Ensure opid ---
818 if ( preg_match( '/data-opid=/', $product_review_section ) ) {
819 $product_review_section = preg_replace( '/data-opid=("|\')(.*?)\1/', 'data-opid="' . $opid_value . '"', $product_review_section );
820 } else {
821 $product_review_section = preg_replace( '/<div\b/i', '<div data-opid="' . esc_attr( $opid_value ) . '"', $product_review_section, 1 );
822 }
823
824 // --- Ensure lang ---
825 if ( ! empty( $current_lang ) ) {
826 if ( preg_match( '/data-lang=/', $product_review_section ) ) {
827 $product_review_section = preg_replace( '/data-lang=("|\')(.*?)\1/', 'data-lang="' . $current_lang . '"', $product_review_section );
828 } else {
829 $product_review_section = preg_replace( '/<div\b/i', '<div data-lang="' . esc_attr( $current_lang ) . '"', $product_review_section, 1 );
830 }
831 }
832
833 // --- Extract the wrapper div tag ---
834 $wiserrw_open_tag = '';
835 if ( preg_match( '/<div\b[^>]*>/i', $product_review_section, $matches ) ) {
836 $wiserrw_open_tag = $matches[0];
837
838 // --- Ensure opid for wrapper ---
839 if ( preg_match( '/data-opid=/', $wiserrw_open_tag ) ) {
840 $wiserrw_open_tag = preg_replace( '/data-opid=("|\')(.*?)\1/', 'data-opid="' . $opid_value . '"', $wiserrw_open_tag );
841 } else {
842 $wiserrw_open_tag = str_replace( '<div', '<div data-opid="' . esc_attr( $opid_value ) . '"', $wiserrw_open_tag );
843 }
844
845 // --- Ensure lang for wrapper ---
846 if ( ! empty( $current_lang ) ) {
847 if ( preg_match( '/data-lang=/', $wiserrw_open_tag ) ) {
848 $wiserrw_open_tag = preg_replace( '/data-lang=("|\')(.*?)\1/', 'data-lang="' . $current_lang . '"', $wiserrw_open_tag );
849 } else {
850 $wiserrw_open_tag = str_replace( '<div', '<div data-lang="' . esc_attr( $current_lang ) . '"', $wiserrw_open_tag );
851 }
852 }
853 }
854
855 // --- Use post meta if available, fallback if not ---
856 $meta_html = get_post_meta( $product_id, 'wiserrw_product_html', true );
857 //if ( $meta_html ) {
858 // Patch opid for meta HTML
859 if ( preg_match( '/data-opid=/', $meta_html ) ) {
860 $meta_html = preg_replace( '/data-opid=("|\')(.*?)\1/', 'data-opid="' . $opid_value . '"', $meta_html );
861 } else {
862 $meta_html = preg_replace( '/<div\b/i', '<div data-opid="' . esc_attr( $opid_value ) . '"', $meta_html, 1 );
863 }
864
865 // Patch lang for meta HTML
866 if ( ! empty( $current_lang ) ) {
867 if ( preg_match( '/data-lang=/', $meta_html ) ) {
868 $meta_html = preg_replace( '/data-lang=("|\')(.*?)\1/', 'data-lang="' . $current_lang . '"', $meta_html );
869 } else {
870 $meta_html = preg_replace( '/<div\b/i', '<div data-lang="' . esc_attr( $current_lang ) . '"', $meta_html, 1 );
871 }
872 }
873
874 $inner_html = $meta_html;
875
876 // Build output
877 $output = '';
878 if ( $wiserrw_open_tag ) {
879 $output .= $wiserrw_open_tag;
880 }
881 $output .= $inner_html;
882 $output .= '</div>';
883
884 return $output;
885 }
886 add_shortcode( 'wiserrw_product_review', 'wiserrw_product_review' );
887
888 /**
889 * Shortcode for the Nudges.
890 */
891 function wiserrw_product_nudges( $atts = [] ) {
892 $atts = shortcode_atts(
893 array(
894 'id' => wiserrw_get_current_product_id(),
895 ),
896 $atts,
897 'wiserrw_product_nudges'
898 );
899
900 $product_id = intval( $atts['id'] );
901 if ( ! $product_id ) {
902 return '';
903 }
904
905 $product = wc_get_product( $product_id );
906 if ( ! $product ) {
907 return '';
908 }
909
910 $wiserrw_api_settings = get_option( 'wiserrw_api_settings' );
911 if ( ! $wiserrw_api_settings || '1' !== ( $wiserrw_api_settings['wiserrw_live_mode_checkbox'] ?? '' ) ) {
912 return '';
913 }
914
915 $sku = $product->get_sku();
916 $wiserrw_api_data = get_option( 'wiserrw_api_data' );
917
918 // --- Multilingual (WPML / Polylang) ---
919 $original_pid = 0;
920 $current_lang = '';
921
922 // WPML
923 if ( has_filter( 'wpml_element_trid' ) ) {
924 $trid = apply_filters( 'wpml_element_trid', null, $product_id, 'post_product' );
925 $translations = $trid ? apply_filters( 'wpml_get_element_translations', null, $trid, 'post_product' ) : array();
926 $current_lang = apply_filters( 'wpml_current_language', null );
927
928 if ( $translations && is_array( $translations ) ) {
929 foreach ( $translations as $lang => $translation ) {
930 if ( ! empty( $translation->original ) ) {
931 $original_pid = (int) $translation->element_id;
932 }
933 }
934 }
935 }
936
937 // Polylang
938 if ( function_exists( 'pll_get_post' ) && function_exists( 'pll_current_language' ) ) {
939 $current_lang = pll_current_language();
940 $default_lang = pll_default_language();
941 if ( $current_lang && $default_lang && $current_lang !== $default_lang ) {
942 $original_pid = pll_get_post( $product_id, $default_lang );
943 }
944 }
945
946 // Fallback if no original found
947 $opid_value = ( $original_pid && $original_pid !== $product_id ) ? $original_pid : $product_id;
948 $current_lang = wiserrw_normalize_lang( $current_lang );
949
950 // --- Patch API HTML ---
951 $nudges_section = $wiserrw_api_data->nudges_section ?? '';
952
953 // Ensure pid
954 $nudges_section = preg_replace( '/data-pid=("|\')(.*?)\1/', 'data-pid="' . $product_id . '"', $nudges_section );
955
956 // Ensure skuid
957 $nudges_section = preg_replace( '/data-skuid=("|\')(.*?)\1/', 'data-skuid="' . $sku . '"', $nudges_section );
958
959 // Ensure opid
960 if ( preg_match( '/data-opid=/', $nudges_section ) ) {
961 $nudges_section = preg_replace( '/data-opid=("|\')(.*?)\1/', 'data-opid="' . $opid_value . '"', $nudges_section );
962 } else {
963 $nudges_section = preg_replace( '/<div\b/i', '<div data-opid="' . esc_attr( $opid_value ) . '"', $nudges_section, 1 );
964 }
965
966 // Ensure lang
967 if ( ! empty( $current_lang ) ) {
968 if ( preg_match( '/data-lang=/', $nudges_section ) ) {
969 $nudges_section = preg_replace( '/data-lang=("|\')(.*?)\1/', 'data-lang="' . $current_lang . '"', $nudges_section );
970 } else {
971 $nudges_section = preg_replace( '/<div\b/i', '<div data-lang="' . esc_attr( $current_lang ) . '"', $nudges_section, 1 );
972 }
973 }
974
975 return $nudges_section;
976 }
977 add_shortcode( 'wiserrw_product_nudges', 'wiserrw_product_nudges' );
978 // Alias: the settings page advertises [wiserrw_review_nudges] (the tooltip,
979 // the copyable shortcode field, and the "with product ID" example all use
980 // this name). Register it as an alias of the same handler so the documented
981 // shortcode works, while [wiserrw_product_nudges] keeps working for merchants
982 // who already discovered the original name.
983 add_shortcode( 'wiserrw_review_nudges', 'wiserrw_product_nudges' );
984
985
986 // ─────────────────────────────────────────────────────────────────────────────
987 // LEAVE A REVIEW — My Account page + inline order links
988 // ─────────────────────────────────────────────────────────────────────────────
989
990 /**
991 * Build a review form URL for a given product.
992 *
993 * @param int $product_id WC product ID.
994 * @param int|null $order_id WC order ID (enables verified-buyer matching).
995 * @return string|false Full URL, or false if not configured.
996 */
997 function wiserrw_review_form_url( $product_id, $order_id = null, $sku = '' ) {
998 $settings = get_option( 'wiserrw_api_settings', array() );
999
1000 if ( empty( $settings['wiserrw_live_mode_checkbox'] ) || '1' !== $settings['wiserrw_live_mode_checkbox'] ) {
1001 return false;
1002 }
1003
1004 if ( empty( $settings['wiserrw_review_page_enabled'] ) || '1' !== $settings['wiserrw_review_page_enabled'] ) {
1005 return false;
1006 }
1007
1008 $base_url = $settings['wiserrw_review_form_url'] ?? '';
1009 if ( ! $base_url || strpos( $base_url, 'https://rating.wiserreview.com/' ) !== 0 ) {
1010 return false;
1011 }
1012
1013 $params = array( 'pid' => (int) $product_id );
1014 if ( $order_id ) {
1015 $params['oid'] = (int) $order_id;
1016 }
1017 if ( ! empty( $sku ) ) {
1018 $params['sku'] = (string) $sku;
1019 }
1020
1021 return $base_url . '?' . http_build_query( $params );
1022 }
1023
1024 /**
1025 * My Account endpoint: "leave-a-review"
1026 */
1027 define( 'WISERRW_REVIEW_ENDPOINT', 'leave-a-review' );
1028
1029 add_action( 'init', 'wiserrw_review_page_add_endpoint' );
1030 function wiserrw_review_page_add_endpoint() {
1031 $settings = get_option( 'wiserrw_api_settings', array() );
1032 $enabled = ! empty( $settings['wiserrw_review_page_enabled'] ) && '1' === $settings['wiserrw_review_page_enabled'];
1033
1034 if ( $enabled ) {
1035 add_rewrite_endpoint( WISERRW_REVIEW_ENDPOINT, EP_ROOT | EP_PAGES );
1036 } else {
1037 // Clean up stale rewrite rules if endpoint was previously registered
1038 $rules = get_option( 'rewrite_rules', array() );
1039 if ( is_array( $rules ) && isset( $rules[ WISERRW_REVIEW_ENDPOINT . '(/(.*))?/?$' ] ) ) {
1040 flush_rewrite_rules();
1041 }
1042 }
1043 }
1044
1045 add_filter( 'woocommerce_account_menu_items', 'wiserrw_review_page_menu_item' );
1046 function wiserrw_review_page_menu_item( array $items ): array {
1047 $settings = get_option( 'wiserrw_api_settings', array() );
1048 if ( empty( $settings['wiserrw_review_page_enabled'] ) || '1' !== $settings['wiserrw_review_page_enabled'] ) {
1049 return $items;
1050 }
1051
1052 $new = array();
1053 foreach ( $items as $key => $label ) {
1054 if ( $key === 'customer-logout' ) {
1055 $new[ WISERRW_REVIEW_ENDPOINT ] = __( 'Leave a Review', 'wiser-review' );
1056 }
1057 $new[ $key ] = $label;
1058 }
1059 return $new;
1060 }
1061
1062 add_action( 'woocommerce_account_' . WISERRW_REVIEW_ENDPOINT . '_endpoint', 'wiserrw_review_page_content' );
1063 function wiserrw_review_page_content() {
1064 // Don't render if feature is disabled
1065 $settings = get_option( 'wiserrw_api_settings', array() );
1066 if ( empty( $settings['wiserrw_review_page_enabled'] ) || '1' !== $settings['wiserrw_review_page_enabled'] ) {
1067 return;
1068 }
1069
1070 $customer_id = get_current_user_id();
1071 if ( ! $customer_id ) {
1072 echo '<p>' . esc_html__( 'Please log in to see your purchased products.', 'wiser-review' ) . '</p>';
1073 return;
1074 }
1075
1076 // Only fulfilled orders — an un-shipped / cancelled / refunded order
1077 // shouldn't invite a review. Matches the inline order-item button gate.
1078 $orders = wc_get_orders( array(
1079 'customer_id' => $customer_id,
1080 'status' => array( 'completed' ),
1081 'limit' => -1,
1082 'orderby' => 'date',
1083 'order' => 'DESC',
1084 ) );
1085
1086 // Collect unique products/variations, keeping most recent order ID per variation
1087 $products = array();
1088 $order_ids = array();
1089 $variation_data = array(); // key => array( 'attributes_text', 'image_url' )
1090 $parent_cache = array(); // avoid repeated wc_get_product() calls for same parent
1091 foreach ( $orders as $order ) {
1092 foreach ( $order->get_items() as $item ) {
1093 if ( ! $item instanceof WC_Order_Item_Product ) {
1094 continue;
1095 }
1096 $parent_id = $item->get_product_id();
1097 $variation_id = $item->get_variation_id();
1098 // Use variation+parent combo as unique key (so each variation shows as its own card)
1099 $key = $variation_id ? $parent_id . '_' . $variation_id : (string) $parent_id;
1100
1101 if ( isset( $products[ $key ] ) ) {
1102 continue;
1103 }
1104
1105 // Load parent (cached) and variation product
1106 if ( ! isset( $parent_cache[ $parent_id ] ) ) {
1107 $parent_cache[ $parent_id ] = wc_get_product( $parent_id );
1108 }
1109 $parent_product = $parent_cache[ $parent_id ];
1110 $display_product = $variation_id ? wc_get_product( $variation_id ) : $parent_product;
1111
1112 if ( ! $display_product || ! $parent_product || ! $parent_product->is_visible() ) {
1113 continue;
1114 }
1115
1116 // Variation attributes as readable text (e.g., "Size: XS, Color: Green")
1117 $attributes_text = $variation_id ? wc_get_formatted_variation( $display_product, true, false, true ) : '';
1118
1119 // Prefer variation image, fall back to parent image
1120 $image_id = $display_product->get_image_id() ?: $parent_product->get_image_id();
1121 $image_url = $image_id ? wp_get_attachment_image_url( $image_id, 'woocommerce_thumbnail' ) : '';
1122
1123 $products[ $key ] = $parent_product; // parent used for display (name) + review URL pid
1124 $order_ids[ $key ] = $order->get_id();
1125 $variation_data[ $key ] = array(
1126 'attributes_text' => $attributes_text,
1127 'image_url' => $image_url,
1128 'sku' => $display_product->get_sku(), // variant SKU (falls back to parent SKU if variant has no SKU)
1129 );
1130 }
1131 }
1132
1133 if ( empty( $products ) ) {
1134 echo '<p>' . esc_html__( 'You have no completed orders with reviewable products yet.', 'wiser-review' ) . '</p>';
1135 return;
1136 }
1137 ?>
1138 <style>
1139 .wiserrw-review-grid {
1140 display: grid;
1141 grid-template-columns: repeat( auto-fill, minmax( 200px, 1fr ) );
1142 gap: 20px;
1143 margin: 20px 0;
1144 }
1145 .wiserrw-review-card {
1146 border: 1px solid #e5e5e5;
1147 border-radius: 8px;
1148 overflow: hidden;
1149 display: flex;
1150 flex-direction: column;
1151 background: #fff;
1152 transition: box-shadow .15s;
1153 }
1154 .wiserrw-review-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.1); }
1155 .wiserrw-review-card-img {
1156 width: 100%;
1157 aspect-ratio: 1;
1158 object-fit: cover;
1159 background: #f7f7f7;
1160 }
1161 .wiserrw-review-card-img.placeholder {
1162 display: flex;
1163 align-items: center;
1164 justify-content: center;
1165 font-size: 40px;
1166 color: #ccc;
1167 }
1168 .wiserrw-review-card-body {
1169 padding: 12px;
1170 flex: 1;
1171 display: flex;
1172 flex-direction: column;
1173 gap: 10px;
1174 }
1175 .wiserrw-review-card-name {
1176 font-size: 13px;
1177 font-weight: 600;
1178 line-height: 1.4;
1179 color: #1d2327;
1180 }
1181 .wiserrw-review-card-attrs {
1182 font-size: 11px;
1183 color: #757575;
1184 line-height: 1.4;
1185 margin-top: 2px;
1186 }
1187 .wiserrw-review-card-attrs dl,
1188 .wiserrw-review-card-attrs dt,
1189 .wiserrw-review-card-attrs dd {
1190 display: inline;
1191 margin: 0;
1192 padding: 0;
1193 }
1194 .wiserrw-review-card-attrs dt { font-weight: 500; }
1195 .wiserrw-review-card-attrs dt::after { content: ": "; }
1196 .wiserrw-review-card-attrs dd::after { content: " "; }
1197 .wiserrw-review-card-link {
1198 display: block;
1199 text-align: center;
1200 padding: 8px 12px;
1201 background: #2271b1;
1202 color: #fff !important;
1203 text-decoration: none !important;
1204 border-radius: 5px;
1205 font-size: 13px;
1206 font-weight: 600;
1207 transition: background .15s;
1208 margin-top: auto;
1209 }
1210 .wiserrw-review-card-link:hover { background: #135e96; }
1211 </style>
1212
1213 <h3><?php esc_html_e( 'Leave a Review', 'wiser-review' ); ?></h3>
1214 <p><?php esc_html_e( 'Thank you for shopping with us! Click any product below to leave a review.', 'wiser-review' ); ?></p>
1215
1216 <div class="wiserrw-review-grid">
1217 <?php foreach ( $products as $key => $product ) :
1218 $vdata = $variation_data[ $key ] ?? array();
1219 $sku_for_url = $vdata['sku'] ?? '';
1220 // Always pass parent product ID as pid; variant-level tracking happens via sku.
1221 $review_url = wiserrw_review_form_url( $product->get_id(), $order_ids[ $key ] ?? null, $sku_for_url );
1222 $thumb_url = ! empty( $vdata['image_url'] ) ? $vdata['image_url'] : get_the_post_thumbnail_url( $product->get_id(), 'woocommerce_thumbnail' );
1223 $attr_text = $vdata['attributes_text'] ?? '';
1224 ?>
1225 <div class="wiserrw-review-card">
1226 <?php if ( $thumb_url ) : ?>
1227 <img class="wiserrw-review-card-img"
1228 src="<?php echo esc_url( $thumb_url ); ?>"
1229 alt="<?php echo esc_attr( $product->get_name() ); ?>" loading="lazy" />
1230 <?php else : ?>
1231 <div class="wiserrw-review-card-img placeholder" aria-hidden="true">&#128230;</div>
1232 <?php endif; ?>
1233
1234 <div class="wiserrw-review-card-body">
1235 <div class="wiserrw-review-card-name">
1236 <?php echo esc_html( $product->get_name() ); ?>
1237 </div>
1238 <?php if ( ! empty( $attr_text ) ) : ?>
1239 <div class="wiserrw-review-card-attrs"><?php echo wp_kses_post( $attr_text ); ?></div>
1240 <?php endif; ?>
1241
1242 <?php if ( $review_url ) : ?>
1243 <a href="<?php echo esc_url( $review_url ); ?>"
1244 target="_blank"
1245 rel="noopener noreferrer"
1246 class="wiserrw-review-card-link">
1247 &#11088; <?php esc_html_e( 'Leave a Review', 'wiser-review' ); ?>
1248 </a>
1249 <?php endif; ?>
1250 </div>
1251 </div>
1252 <?php endforeach; ?>
1253 </div>
1254 <?php
1255 }
1256
1257 /**
1258 * Inline "Leave a Review" link per product on My Account → View Order page.
1259 */
1260 add_action( 'wp_enqueue_scripts', 'wiserrw_frontend_review_link_styles' );
1261 function wiserrw_frontend_review_link_styles() {
1262 if ( ! is_wc_endpoint_url() && ! is_account_page() ) {
1263 return;
1264 }
1265 $settings = get_option( 'wiserrw_api_settings', array() );
1266 if ( empty( $settings['wiserrw_review_page_enabled'] ) || '1' !== $settings['wiserrw_review_page_enabled'] ) {
1267 return;
1268 }
1269 $css = '.wiserrw-inline-review-link {
1270 display: inline-block; padding: 4px 10px; border: 1px solid currentColor;
1271 border-radius: 4px; font-size: 12px; font-weight: 600;
1272 text-decoration: none; opacity: 0.85; transition: opacity .15s; margin-top: 6px;
1273 }
1274 .wiserrw-inline-review-link:hover { opacity: 1; text-decoration: none; }';
1275
1276 // Star icon prefix on the "Leave a Review" menu item. Default: shown.
1277 // Merchants on minimalist themes can opt out via the sub-checkbox in the
1278 // settings page (key 'wiserrw_review_menu_icon_hidden'). Existing stores
1279 // without the key set keep the icon — preserves the look they shipped with.
1280 if ( empty( $settings['wiserrw_review_menu_icon_hidden'] ) ) {
1281 $css .= '
1282 .woocommerce-MyAccount-navigation ul li.woocommerce-MyAccount-navigation-link--leave-a-review a::before {
1283 content: "";
1284 display: inline-block;
1285 width: 16px; height: 16px;
1286 margin-right: 6px;
1287 vertical-align: middle;
1288 background: currentColor;
1289 -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 20 20\'%3E%3Cpath d=\'M10 1.12l2.77 5.61 6.19.9-4.48 4.37 1.06 6.17L10 15.27l-5.54 2.9 1.06-6.17L1.04 7.63l6.19-.9L10 1.12z\'/%3E%3C/svg%3E") center/contain no-repeat;
1290 mask: url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 20 20\'%3E%3Cpath d=\'M10 1.12l2.77 5.61 6.19.9-4.48 4.37 1.06 6.17L10 15.27l-5.54 2.9 1.06-6.17L1.04 7.63l6.19-.9L10 1.12z\'/%3E%3C/svg%3E") center/contain no-repeat;
1291 }';
1292 }
1293 wp_register_style( 'wiserrw-review-link', false );
1294 wp_enqueue_style( 'wiserrw-review-link' );
1295 wp_add_inline_style( 'wiserrw-review-link', $css );
1296 }
1297
1298 add_action( 'woocommerce_order_item_meta_end', 'wiserrw_inline_order_review_link', 10, 3 );
1299 function wiserrw_inline_order_review_link( $item_id, $item, $order ) {
1300 // Only show on My Account pages
1301 if ( ! is_account_page() ) {
1302 return;
1303 }
1304 if ( ! $item instanceof WC_Order_Item_Product ) {
1305 return;
1306 }
1307 // Only show for fulfilled orders — an un-shipped / cancelled / refunded
1308 // order shouldn't invite a review. Matches WooCommerce's convention that
1309 // "Completed" means the customer has physically received the product.
1310 if ( ! $order || 'completed' !== $order->get_status() ) {
1311 return;
1312 }
1313 $product = $item->get_product(); // returns WC_Product_Variation for variations
1314 if ( ! $product ) {
1315 return;
1316 }
1317 // Always pass parent product ID as pid; variant-level tracking happens via sku.
1318 $sku_for_url = $product->get_sku(); // variant SKU when variant, else parent SKU (WC falls back)
1319 $url = wiserrw_review_form_url( $item->get_product_id(), $order->get_id(), $sku_for_url );
1320 if ( ! $url ) {
1321 return;
1322 }
1323 echo '<div>'
1324 . '<a href="' . esc_url( $url ) . '" '
1325 . 'target="_blank" rel="noopener noreferrer" '
1326 . 'class="wiserrw-inline-review-link">'
1327 . '&#11088;&nbsp;' . esc_html__( 'Leave a Review', 'wiser-review' )
1328 . '</a>'
1329 . '</div>';
1330 }
1331
1332 /**
1333 * Flush rewrite rules when the review page feature is toggled.
1334 */
1335 register_activation_hook( WISERRW_PLUGIN_FILE, 'wiserrw_review_page_flush_on_activate' );
1336 function wiserrw_review_page_flush_on_activate() {
1337 wiserrw_review_page_add_endpoint();
1338 flush_rewrite_rules();
1339 }
1340
1341 register_deactivation_hook( WISERRW_PLUGIN_FILE, 'wiserrw_review_page_flush_on_deactivate' );
1342 function wiserrw_review_page_flush_on_deactivate() {
1343 // Remove the endpoint by not registering it, then flush to clean up rewrite rules
1344 remove_action( 'init', 'wiserrw_review_page_add_endpoint' );
1345 flush_rewrite_rules();
1346 }
1347
1348
1349 /**
1350 * Send WooCommercer Order Data.
1351 *
1352 * @param int $order_id API Key to validate.
1353 */
1354 function wiserrw_woocommerce_order_status_completed( $order_id ) {
1355 $wiserrw_api_settings = get_option('wiserrw_api_settings', array());
1356 if (empty($wiserrw_api_settings) || empty($wiserrw_api_settings['wiserrw_api_key'])) {
1357 return; // Exit if no API settings
1358 }
1359
1360 $api_key = $wiserrw_api_settings['wiserrw_api_key'];
1361 $wsid = get_option( 'wiserrw_wsid', true );
1362 $automation_id = get_option( 'wiserrw_automation_id', true );
1363 if (!isset($wsid) || !isset($automation_id)) {
1364 return; // Exit if invalid API data
1365 }
1366
1367 $order = wc_get_order($order_id);
1368 if (!$order) {
1369 return; // Exit if invalid order
1370 }
1371
1372 $api_endpoint = WISERRW_API_HOST . 'webhook?wsid=' . $wsid . '&atmid=' . $automation_id.'&ver='.WISERRW_PLUGIN_VERSION;
1373
1374 // Initialize arrays
1375 $wiserrw_order_data = array();
1376 $customer_data = array();
1377
1378 /* Order Details */
1379 $wiserrw_order_data['oid'] = $order->get_id();
1380 $date_created = $order->get_date_created();
1381 $wiserrw_order_data['cdt'] = $date_created ? $date_created->format('Y-m-d H:i:s') : current_time('mysql');
1382 $wiserrw_order_data['orderAmount'] = $order->get_total();
1383
1384 /* Shipping Details */
1385 $country = $order->get_shipping_country();
1386 $state = $order->get_shipping_state();
1387 // Fallback to billing if shipping not available
1388 if ( empty( $country ) ) {
1389 $country = $order->get_billing_country();
1390 }
1391 if ( empty( $state ) ) {
1392 $state = $order->get_billing_state();
1393 }
1394
1395 /* Customer Details */
1396 $customer_data['email'] = $order->get_billing_email();
1397 $customer_data['phone'] = $order->get_billing_phone();
1398 $customer_data['first_name'] = $order->get_billing_first_name();
1399 $customer_data['last_name'] = $order->get_billing_last_name();
1400
1401 // Address
1402 $customer_data['cntry'] = $country;
1403 $customer_data['state'] = $state;
1404
1405 $wiserrw_order_data['customer'] = $customer_data;
1406
1407 /* Product Details */
1408 $wiserrw_order_data['line_items'] = array(); // Initialize line items array
1409 $items = $order->get_items();
1410
1411 foreach ( $items as $item ) {
1412 $product = $item->get_product();
1413 if (!$product || !$product instanceof WC_Product) {
1414 continue; // Skip if product doesn't exist anymore
1415 }
1416
1417 $product_id = $item->get_product_id();
1418 $variation_id = $item->get_variation_id();
1419 $product_price = $product->get_price();
1420 $url = get_permalink($product_id);
1421 $product_name = $item->get_name();
1422 // Variant SKU if the purchased item is a variant, else parent SKU (WC get_sku() on a variation falls back to parent SKU automatically).
1423 $product_sku = $product->get_sku();
1424
1425 // --- Multilingual handling (Added for opid/lang) ---
1426 $original_pid = 0;
1427 $current_lang = '';
1428
1429 // WPML
1430 if ( has_filter( 'wpml_element_trid' ) ) {
1431 $trid = apply_filters( 'wpml_element_trid', null, $product_id, 'post_product' );
1432 $translations = $trid ? apply_filters( 'wpml_get_element_translations', null, $trid, 'post_product' ) : [];
1433 $current_lang = apply_filters( 'wpml_current_language', null );
1434 if ( $translations && is_array( $translations ) ) {
1435 foreach ( $translations as $lang => $translation ) {
1436 if ( ! empty( $translation->original ) ) {
1437 $original_pid = (int) $translation->element_id;
1438 }
1439 }
1440 }
1441 }
1442
1443 // Polylang
1444 if ( function_exists( 'pll_get_post' ) && function_exists( 'pll_current_language' ) ) {
1445 $current_lang = pll_current_language();
1446 $default_lang = pll_default_language();
1447 if ( $current_lang && $default_lang && $current_lang !== $default_lang ) {
1448 $original_pid = pll_get_post( $product_id, $default_lang );
1449 }
1450 }
1451
1452 // Fallback (if no translation plugin, opid = current product id)
1453 $opid_value = ( $original_pid && $original_pid !== $product_id ) ? $original_pid : $product_id;
1454
1455 // Prefer variation image if the purchased item is a variant, fall back to parent product image.
1456 $image_id = $variation_id ? (int) $product->get_image_id() : 0;
1457 $image_id = $image_id ?: (int) get_post_thumbnail_id( $product_id );
1458 $image = $image_id ? wp_get_attachment_image_src( $image_id, 'single-post-thumbnail' ) : false;
1459 $image_url = is_array($image) ? $image[0] : '';
1460
1461 // Create new product data array for each item
1462 $product_data = array(
1463 'id' => $product_id,
1464 'pn' => $product_name,
1465 'name' => $product_name,
1466 'prc' => $product_price,
1467 'pu' => $url,
1468 'piu' => $image_url,
1469 'sku' => $product_sku,
1470 'opid' => $opid_value,
1471 'lang' => wiserrw_normalize_lang( $current_lang ),
1472 );
1473
1474 $wiserrw_order_data['line_items'][] = $product_data;
1475 }
1476
1477 // Allow filtering/modification of the final payload before sending to API: use for referral widget
1478 $wiserrw_order_data = apply_filters( 'wiserrw_order_webhook_payload', $wiserrw_order_data, $order );
1479
1480 // Send data to API
1481 $response = wp_remote_post(
1482 $api_endpoint,
1483 array(
1484 'method' => 'POST',
1485 'headers' => array(
1486 'Content-Type' => 'application/json',
1487 'x-wiserrw-api-key' => $api_key,
1488 ),
1489 'timeout' => 45,
1490 'redirection' => 5,
1491 'httpversion' => '1.0',
1492 'blocking' => true,
1493 'body' => wp_json_encode($wiserrw_order_data),
1494 )
1495 );
1496
1497 if (is_wp_error($response)) {
1498 $error_message = $response->get_error_message();
1499 error_log(sprintf(
1500 'WiserReview API Error (Order #%d): %s',
1501 $order_id,
1502 $error_message
1503 ));
1504 } else {
1505 $response_code = wp_remote_retrieve_response_code($response);
1506 if ($response_code !== 200) {
1507 $response_body = wp_remote_retrieve_body($response);
1508 error_log(sprintf(
1509 'WiserReview API Non-200 Response (Order #%d): Code %d - %s',
1510 $order_id,
1511 $response_code,
1512 $response_body
1513 ));
1514 }
1515 }
1516 }
1517
1518 add_action( 'woocommerce_order_status_completed', 'wiserrw_woocommerce_order_status_completed' );
1519
1520 /**
1521 * Export Orders.
1522 */
1523 function wiserrw_export_orders() {
1524 check_ajax_referer( 'wiserrw_export_orders_nonce', 'wiserrw_security' );
1525 if ( ! current_user_can( 'manage_woocommerce' ) ) {
1526 wp_send_json_error( array( 'message' => 'You are not allowed to perform this action.' ), 403 );
1527 }
1528 $from_date = isset( $_POST['start_date'] ) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : '';
1529 $to_date = isset( $_POST['end_date'] ) ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) ) : '';
1530 $duration = isset( $_POST['duration'] ) ? sanitize_text_field( wp_unslash( $_POST['duration'] ) ) : '';
1531 $offset = isset( $_POST['offset'] ) ? intval( sanitize_text_field( wp_unslash( $_POST['offset'] ) ) ) : '';
1532 $limit = isset( $_POST['limit'] ) ? intval( sanitize_text_field( wp_unslash( $_POST['limit'] ) ) ) : '';
1533
1534 if ( '' !== $duration ) {
1535 switch ( $duration ) {
1536 case '30_days':
1537 $date_from = strtotime( '-30 days' );
1538 break;
1539 case '3_months':
1540 $date_from = strtotime( '-3 months' );
1541 break;
1542 case '6_months':
1543 $date_from = strtotime( '-6 months' );
1544 break;
1545 case '1_year':
1546 $date_from = strtotime( '-1 year' );
1547 break;
1548 case '7_days':
1549 $date_from = strtotime( '-7 days' );
1550 break;
1551 default:
1552 $date_from = strtotime( '-7 days' );
1553 break;
1554 }
1555 // Get orders between date_from and now
1556 $date_to = current_time('timestamp');
1557 $all_orders = wc_get_orders(
1558 array(
1559 'status' => array( 'completed', 'processing' ),
1560 'date_created' => $date_from . '...' . $date_to, // Using WooCommerce's date range format
1561 'limit' => -1,
1562 )
1563 );
1564 } else {
1565 // For custom date range, ensure we get full days
1566 $start_date = strtotime($from_date . ' 00:00:00');
1567 $end_date = strtotime($to_date . ' 23:59:59');
1568
1569 if (!$start_date || !$end_date) {
1570 wp_send_json_error(array('message' => 'Invalid date range provided'));
1571 die();
1572 }
1573
1574 $all_orders = wc_get_orders(
1575 array(
1576 'status' => array( 'completed', 'processing' ),
1577 'date_created' => $start_date . '...' . $end_date, // Using WooCommerce's date range format
1578 'limit' => -1,
1579 )
1580 );
1581 }
1582 $total = count( $all_orders );
1583 $chunk_ids = array_slice( $all_orders, $offset, $limit );
1584 $upload_dir = wp_upload_dir();
1585 $filename = 'orders-export-progress.csv';
1586 $filepath = $upload_dir['basedir'] . '/' . $filename;
1587 $is_first_chunk = 0 === $offset;
1588
1589 $fp = fopen( $filepath, $is_first_chunk ? 'w' : 'a' );
1590 if ( $fp ) {
1591 if ( $is_first_chunk ) {
1592 fputcsv( $fp, array('Order Id', 'First Name', 'Last Name', 'Phone', 'Email', 'Country', 'State', 'Product Title', 'Product URL', 'Product Image URL', 'Product ID', 'Reviewer Image URL', 'Order Date' ) );
1593 }
1594
1595 foreach ( $chunk_ids as $order) {
1596 $order_id = $order->get_id();
1597 $user_id = $order->get_user_id();
1598
1599
1600 $country = $order->get_shipping_country();
1601 $state = $order->get_shipping_state();
1602
1603 // Fallback to billing if shipping not available
1604 if ( empty( $country ) ) {
1605 $country = $order->get_billing_country();
1606 }
1607 if ( empty( $state ) ) {
1608 $state = $order->get_billing_state();
1609 }
1610
1611 /* Product Details */
1612 $items = $order->get_items();
1613 foreach ( $items as $item ) {
1614 $product = $item->get_product();
1615 if (!$product) {
1616 continue;
1617 }
1618
1619 $product_id = $item->get_product_id();
1620 $product_price = $product->get_price();
1621 $url = get_permalink( $product_id );
1622 $product_name = $item->get_name();
1623 $image = wp_get_attachment_image_src( get_post_thumbnail_id( $product_id ), 'single-post-thumbnail' );
1624 $image_url = is_array($image) ? $image[0] : '';
1625 $reviewer_url = get_avatar_url( $user_id );
1626 $products_str[] = $product->get_name() . "|" . $image_url;
1627
1628 fputcsv(
1629 $fp,
1630 array(
1631 $order_id,
1632 $order->get_billing_first_name(),
1633 $order->get_billing_last_name(),
1634 $order->get_billing_phone(),
1635 $order->get_billing_email(),
1636 $country,
1637 $state,
1638 $product->get_name(),
1639 $url,
1640 $image_url,
1641 $order->get_id(),
1642 $reviewer_url,
1643 $order->get_date_created()->date( 'Y-m-d H:i:s' ),
1644 )
1645 );
1646 }
1647 }
1648
1649 fclose( $fp );
1650 }
1651
1652 $processed = $offset + count( $chunk_ids );
1653 $percent = round( ( $processed / $total ) * 100 );
1654
1655 wp_send_json_success(
1656 array(
1657 'percent' => $percent,
1658 'done' => $processed >= $total,
1659 'file_url' => $upload_dir['baseurl'] . '/' . $filename,
1660 )
1661 );
1662 }
1663
1664 add_action( 'wp_ajax_wiserrw_export_orders', 'wiserrw_export_orders' );
1665
1666 /**
1667 * Bulk Orders API.
1668 */
1669 function wiserrw_bulk_orders_send() {
1670
1671 check_ajax_referer( 'wiserrw_export_orders_nonce', 'wiserrw_security' );
1672
1673 if ( ! current_user_can( 'manage_woocommerce' ) ) {
1674 wp_send_json_error(
1675 array( 'message' => __( 'You are not allowed to perform this action.', 'wiser-review' ) ),
1676 403
1677 );
1678 }
1679
1680 $wiserrw_api_settings = get_option( 'wiserrw_api_settings' );
1681 $api_key = $wiserrw_api_settings['wiserrw_api_key'];
1682 $wsid = get_option( 'wiserrw_wsid', true );
1683 $automation_id = get_option( 'wiserrw_automation_id', true );
1684
1685 if ( !isset($wsid) || !isset($automation_id) ) {
1686 return; // Exit if invalid API data
1687 }
1688
1689 $wiserrw_order_data = array();
1690 $customer_data = array();
1691 $api_endpoint = WISERRW_API_HOST . 'webhook?wsid='
1692 . $wsid . '&atmid=' . $automation_id . '&ver=' . WISERRW_PLUGIN_VERSION;
1693
1694 $from_date = isset( $_POST['start_date'] ) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : '';
1695 $to_date = isset( $_POST['end_date'] ) ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) ) : '';
1696 $duration = isset( $_POST['duration'] ) ? sanitize_text_field( wp_unslash( $_POST['duration'] ) ) : '';
1697 $offset = isset( $_POST['offset'] ) ? intval( sanitize_text_field( wp_unslash( $_POST['offset'] ) ) ) : '';
1698 $limit = isset( $_POST['limit'] ) ? intval( sanitize_text_field( wp_unslash( $_POST['limit'] ) ) ) : '';
1699
1700 if ( '' !== $duration ) {
1701 switch ( $duration ) {
1702 case '30_days': $date_from = strtotime( '-30 days' ); break;
1703 case '3_months': $date_from = strtotime( '-3 months' ); break;
1704 case '6_months': $date_from = strtotime( '-6 months' ); break;
1705 case '1_year': $date_from = strtotime( '-1 year' ); break;
1706 case '7_days': $date_from = strtotime( '-7 days' ); break;
1707 default: $date_from = strtotime( '-7 days' ); break;
1708 }
1709
1710 $date_to = current_time('timestamp');
1711 $all_orders = wc_get_orders(array(
1712 'status' => array( 'completed', 'processing' ),
1713 'date_created' => $date_from . '...' . $date_to,
1714 'limit' => -1,
1715 ));
1716
1717 } else {
1718 $start_date = new WC_DateTime($from_date);
1719 $end_date = new WC_DateTime($to_date);
1720 $all_orders = wc_get_orders(array(
1721 'status' => array( 'completed', 'processing' ),
1722 'date_created' => $start_date->getTimestamp() . '...' . $end_date->getTimestamp(),
1723 'limit' => -1,
1724 ));
1725 }
1726
1727 $total = count( $all_orders );
1728 if ( $total === 0 ) {
1729 wp_send_json_success(array(
1730 'percent' => 100,
1731 'done' => true,
1732 'message' => 'No orders found for selected range.',
1733 ));
1734 wp_die();
1735 }
1736
1737 if ( $total > 0 ) {
1738 $chunk_ids = array_slice( $all_orders, $offset, $limit );
1739 $api_errors = array();
1740
1741 foreach ( $chunk_ids as $order ) {
1742 if ( $order instanceof WC_Order_Refund ) {
1743 continue; // skip refunds
1744 }
1745
1746 $order_id = $order->get_id();
1747
1748 /* Order Details */
1749 $wiserrw_order_data['oid'] = $order_id;
1750 $wiserrw_order_data['cdt'] = $order->get_date_created()
1751 ? $order->get_date_created()->format('Y-m-d H:i:s') // �
1752 Clean date format
1753 : current_time('mysql');
1754 $wiserrw_order_data['orderAmount'] = $order->get_total();
1755
1756 /* Shipping Details */
1757 $country = $order->get_shipping_country();
1758 $state = $order->get_shipping_state();
1759
1760 // Fallback to billing if shipping not available
1761
1762 if ( empty( $country ) ) {
1763 $country = $order->get_billing_country();
1764 }
1765 if ( empty( $state ) ) {
1766 $state = $order->get_billing_state();
1767 }
1768
1769
1770
1771 /* Customer Details */
1772 $customer_data = array(
1773 'email' => $order->get_billing_email(),
1774 'phone' => $order->get_billing_phone(),
1775 'first_name' => $order->get_billing_first_name(),
1776 'last_name' => $order->get_billing_last_name(),
1777
1778 );
1779
1780 $wiserrw_order_data['cntry'] = $country;
1781 $wiserrw_order_data['state'] = $state;
1782 $wiserrw_order_data['customer'] = $customer_data;
1783
1784 /* Product Details */
1785 $wiserrw_order_data['line_items'] = array();
1786 foreach ( $order->get_items() as $item ) {
1787 $product = $item->get_product();
1788 if ( !$product || !$product instanceof WC_Product ) {
1789 continue;
1790 }
1791
1792 $product_id = $item->get_product_id();
1793 $variation_id = $item->get_variation_id();
1794 $product_name = $item->get_name();
1795 $product_price = $product->get_price();
1796 $url = get_permalink($product_id);
1797 // Variant SKU if the purchased item is a variant, else parent SKU (WC falls back automatically).
1798 $product_sku = $product->get_sku();
1799 // Prefer variation image if variant, fall back to parent product image.
1800 $image_id = $variation_id ? (int) $product->get_image_id() : 0;
1801 $image_id = $image_id ?: (int) get_post_thumbnail_id( $product_id );
1802 $image = $image_id ? wp_get_attachment_image_src( $image_id, 'single-post-thumbnail' ) : false;
1803 $image_url = is_array($image) ? $image[0] : '';
1804
1805 // --- Multilingual handling ---
1806 $original_pid = 0;
1807 $current_lang = '';
1808
1809 // WPML
1810 if ( has_filter( 'wpml_element_trid' ) ) {
1811 $trid = apply_filters( 'wpml_element_trid', null, $product_id, 'post_product' );
1812 $translations = $trid ? apply_filters( 'wpml_get_element_translations', null, $trid, 'post_product' ) : [];
1813 $current_lang = apply_filters( 'wpml_current_language', null );
1814 if ( $translations && is_array( $translations ) ) {
1815 foreach ( $translations as $lang => $translation ) {
1816 if ( ! empty( $translation->original ) ) {
1817 $original_pid = (int) $translation->element_id;
1818 }
1819 }
1820 }
1821 }
1822
1823 // Polylang
1824 if ( function_exists( 'pll_get_post' ) && function_exists( 'pll_current_language' ) ) {
1825 $current_lang = pll_current_language();
1826 $default_lang = pll_default_language();
1827 if ( $current_lang && $default_lang && $current_lang !== $default_lang ) {
1828 $original_pid = pll_get_post( $product_id, $default_lang );
1829 }
1830 }
1831
1832 // Fallback
1833 $opid_value = ( $original_pid && $original_pid !== $product_id ) ? $original_pid : $product_id;
1834
1835 $product_data = array(
1836 'id' => $product_id,
1837 'pn' => $product_name,
1838 'name' => $product_name,
1839 'prc' => $product_price,
1840 'pu' => $url,
1841 'piu' => $image_url,
1842 'sku' => $product_sku,
1843 'opid' => $opid_value,
1844 'lang' => wiserrw_normalize_lang( $current_lang ),
1845 );
1846
1847 $wiserrw_order_data['line_items'][] = $product_data;
1848 }
1849
1850 // Send to API
1851 $response = wp_remote_post($api_endpoint, array(
1852 'method' => 'POST',
1853 'headers' => array(
1854 'Content-Type' => 'application/json',
1855 'x-wiserrw-api-key' => $api_key,
1856 ),
1857 'timeout' => 45,
1858 'blocking' => true,
1859 'body' => wp_json_encode($wiserrw_order_data),
1860 ));
1861
1862 if ( is_wp_error($response) ) {
1863 $api_errors[] = $response->get_error_message();
1864 } else {
1865 $code = wp_remote_retrieve_response_code($response);
1866 if ( $code !== 200 ) {
1867 $api_errors[] = wp_remote_retrieve_body($response);
1868 }
1869 }
1870 }
1871
1872 $processed = $offset + count( $chunk_ids );
1873 $percent = min(round( ( $processed / $total ) * 100 ), 100);
1874 $is_done = $processed >= $total;
1875
1876 wp_send_json_success(array(
1877 'percent' => $percent,
1878 'done' => $is_done,
1879 'total_processed' => $is_done ? $total : $processed,
1880 'total_orders' => $total,
1881 'processed_chunk' => count($chunk_ids),
1882 'api_error_messages' => $api_errors
1883 ));
1884 }
1885
1886 die();
1887 }
1888
1889 add_action( 'wp_ajax_wiserrw_bulk_orders_send', 'wiserrw_bulk_orders_send' );
1890
1891 /**
1892 * Bulk Orders count API.
1893 */
1894 function wiserrw_fetch_order_count() {
1895 check_ajax_referer( 'wiserrw_export_orders_nonce', 'wiserrw_security' );
1896 if ( ! current_user_can( 'manage_woocommerce' ) ) {
1897 wp_send_json_error( array( 'message' => __('You are not allowed to perform this action.', 'wiser-review' ) ), 403 );
1898 }
1899 $duration = isset( $_POST['duration'] ) ? sanitize_text_field( wp_unslash( $_POST['duration'] ) ) : '';
1900 $from_date = isset( $_POST['start_date'] ) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : '';
1901 $to_date = isset( $_POST['end_date'] ) ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) ) : '';
1902 $offset = isset( $_POST['offset'] ) ? intval( sanitize_text_field( wp_unslash( $_POST['offset'] ) ) ) : '';
1903 $limit = isset( $_POST['limit'] ) ? intval( sanitize_text_field( wp_unslash( $_POST['limit'] ) ) ) : '';
1904 if ( '' !== $duration ) {
1905 switch ( $duration ) {
1906 case '30_days':
1907 $date_from = strtotime( '-30 days' );
1908 break;
1909 case '3_months':
1910 $date_from = strtotime( '-3 months' );
1911 break;
1912 case '6_months':
1913 $date_from = strtotime( '-6 months' );
1914 break;
1915 case '1_year':
1916 $date_from = strtotime( '-1 year' );
1917 break;
1918 case '7_days':
1919 $date_from = strtotime( '-7 days' );
1920 break;
1921 default:
1922 $date_from = strtotime( '-7 days' );
1923 break;
1924 }
1925 $all_orders = wc_get_orders(
1926 array(
1927 'status' => array( 'completed', 'processing' ),
1928 'date_created' => '>' . gmdate( 'Y-m-d H:i:s', $date_from ),
1929 'limit' => -1,
1930 )
1931 );
1932 } else {
1933 $start_date = new WC_DateTime($from_date);
1934 $end_date = new WC_DateTime($to_date);
1935 $all_orders = wc_get_orders(
1936 array(
1937 'status' => array( 'completed', 'processing' ),
1938 'date_created' => gmdate( $start_date . '...' . $end_date ),
1939 'limit' => -1,
1940 )
1941 );
1942 }
1943
1944 $total = count( $all_orders );
1945 wp_send_json_success(
1946 array(
1947 'total_orders' => $total,
1948 )
1949 );
1950 }
1951 add_action( 'wp_ajax_wiserrw_fetch_order_count', 'wiserrw_fetch_order_count' );
1952
1953 function wiserrw_count_products_with_reviews() {
1954 global $wpdb;
1955
1956 return $wpdb->get_var("
1957 SELECT count(ID)
1958 FROM {$wpdb->posts}
1959 WHERE post_type = 'product'
1960 AND post_status IN ('publish', 'draft')");
1961 }
1962
1963 function wiserrw_get_products( $page, $per_page ) {
1964 global $wpdb;
1965 $offset = ( $page - 1 ) * $per_page;
1966
1967 // ORDER BY ID is required: without it MySQL row order is undefined,
1968 // so concurrent inserts/edits during sync can shift pages and cause
1969 // products to be silently skipped between page boundaries.
1970 // Free in compute terms — ID is the PRIMARY KEY (clustered index).
1971 return $wpdb->get_results( $wpdb->prepare( "
1972 SELECT ID,
1973 post_title as title,
1974 post_name as slug,
1975 post_status,
1976 comment_count
1977 FROM {$wpdb->posts}
1978 WHERE post_type = 'product'
1979 AND post_status IN ('publish', 'draft')
1980 ORDER BY ID ASC
1981 LIMIT %d, %d
1982 ", $offset, $per_page ), ARRAY_A );
1983 }
1984
1985
1986 function wiserrw_sync_products() {
1987 $wiserrw_api_settings = get_option( 'wiserrw_api_settings' );
1988 $api_host = WISERRW_API_HOST;
1989 $per_page = isset( $_REQUEST['per_page'] ) ? intval( $_REQUEST['per_page'] ) : 50;
1990 $api_key = $wiserrw_api_settings['wiserrw_api_key'];
1991 $wsid = get_option( 'wiserrw_wsid', true );
1992 $automation_id = get_option( 'wiserrw_automation_id', true );
1993
1994 if ( ! $wsid || ! $automation_id ) {
1995 wp_die();
1996 }
1997
1998 // Pagination setup
1999 if ( isset( $_REQUEST['total_pages'] ) ) {
2000 $total_pages = (int) sanitize_text_field( wp_unslash( $_REQUEST['total_pages'] ) );
2001 } else {
2002 $count = wiserrw_count_products_with_reviews();
2003 $total_pages = (int) ceil( $count / max( 1, $per_page ) );
2004 }
2005
2006 $page = isset( $_REQUEST['page'] ) ? (int) sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ) : 1;
2007 $finished = 0;
2008
2009 if ( $page <= $total_pages ) {
2010 $message = "Synced $page / $total_pages pages";
2011 $products = wiserrw_get_products( $page, $per_page );
2012 $products_json = [];
2013
2014 foreach ( $products as $prod ) {
2015 $p_id = (int) $prod['ID'];
2016 $registered = get_post_meta( $p_id, '_wiserrw_product_registered', true );
2017 if ( $registered ) {
2018 continue;
2019 }
2020
2021 $pr = wc_get_product( $p_id );
2022 if ( ! $pr ) {
2023 continue;
2024 }
2025
2026 // Collect SKUs
2027 $arrsku = [];
2028 if ( $pr->is_type( 'variable' ) ) {
2029 $parent_sku = (string) $pr->get_sku();
2030 if ( $parent_sku !== '' ) {
2031 $arrsku[] = $parent_sku;
2032 }
2033 $prod_variation = new WC_Product_Variable( $p_id );
2034 foreach ( $prod_variation->get_children() as $vid ) {
2035 $vsku = (string) get_post_meta( $vid, '_sku', true );
2036 if ( $vsku !== '' ) {
2037 $arrsku[] = $vsku;
2038 }
2039 }
2040 } else {
2041 $pr_sku = (string) $pr->get_sku();
2042 if ( $pr_sku !== '' ) {
2043 $arrsku[] = $pr_sku;
2044 }
2045 }
2046
2047 // Representative barcode
2048 $gtin = get_post_meta( $p_id, 'hwp_product_gtin', true )
2049 ?: $pr->get_attribute( 'GTIN' )
2050 ?: $pr->get_attribute( 'EAN' )
2051 ?: $pr->get_attribute( 'ISBN' )
2052 ?: get_post_meta( $p_id, '_global_unique_id', true );
2053
2054 // Brand (from taxonomies, attributes, or meta)
2055 $brand = wiserrw_get_product_brand( $pr );
2056
2057 // Title and URL
2058 $title = $prod['title'];
2059 if ( empty( $title ) ) {
2060 $cats = get_the_terms( $p_id, 'product_cat' );
2061 $title = ( ! empty( $cats ) && ! is_wp_error( $cats ) ) ? $cats[0]->name : get_bloginfo( 'name' );
2062 }
2063
2064 $product_url = get_permalink( $p_id );
2065 if ( ! $product_url || $prod['post_status'] === 'draft' ) {
2066 if ( $prod['post_status'] === 'draft' ) {
2067 $product_url = home_url( '/?post_type=product&p=' . $p_id );
2068 } else {
2069 $shop_page_url = get_permalink( wc_get_page_id( 'shop' ) );
2070 $product_url = $shop_page_url ? $shop_page_url : home_url( '/product/' );
2071 $product_url = trailingslashit( $product_url ) . sanitize_title( $title );
2072 }
2073 }
2074
2075 // Image URL
2076 $img_src = wp_get_attachment_image_src( get_post_thumbnail_id( $p_id ), 'shop_single' );
2077 $img_url = ( is_array( $img_src ) && isset( $img_src[0] ) ) ? $img_src[0] : wc_placeholder_img_src( 'shop_single' );
2078
2079 // --- Multilingual handling (product-specific) ---
2080 $original_pid = 0;
2081 $current_lang = '';
2082
2083 // WPML
2084 if ( has_filter( 'wpml_element_trid' ) ) {
2085 $trid = apply_filters( 'wpml_element_trid', null, $p_id, 'post_product' );
2086 $translations = $trid ? apply_filters( 'wpml_get_element_translations', null, $trid, 'post_product' ) : [];
2087 if ( $translations && is_array( $translations ) ) {
2088 foreach ( $translations as $lang => $translation ) {
2089 if ( ! empty( $translation->original ) ) {
2090 $original_pid = (int) $translation->element_id;
2091 }
2092 if ( (int) $translation->element_id === $p_id ) {
2093 $current_lang = $lang; // �
2094 product-specific language
2095 }
2096 }
2097 }
2098 }
2099
2100 // Polylang
2101 if ( function_exists( 'pll_get_post' ) && function_exists( 'pll_get_post_language' ) ) {
2102 $current_lang = pll_get_post_language( $p_id ); // �
2103 product-specific language
2104 $default_lang = function_exists( 'pll_default_language' ) ? pll_default_language() : '';
2105 if ( $current_lang && $default_lang && $current_lang !== $default_lang ) {
2106 $original_pid = pll_get_post( $p_id, $default_lang );
2107 }
2108 }
2109
2110 // Fallback
2111 $opid_value = ( $original_pid && $original_pid !== $p_id ) ? $original_pid : $p_id;
2112
2113 // Build product payload
2114 $product_json = [
2115 'pid' => $p_id,
2116 'arrsku' => $arrsku,
2117 'shp' => get_site_url(),
2118 'wsid' => $wsid,
2119 'pn' => $title,
2120 'piu' => esc_url( $img_url ),
2121 'pu' => esc_url( $product_url ),
2122 'barcode' => (string) $gtin,
2123 'brand' => (string) $brand,
2124 'opid' => $opid_value,
2125 'lang' => wiserrw_normalize_lang( $current_lang ),
2126 ];
2127
2128 $products_json[] = $product_json;
2129
2130 // Note: do NOT mark as registered here. We only set the flag
2131 // after the batch POST returns 200 below — otherwise a failed
2132 // API call (network error, 5xx, timeout) would silently lose
2133 // up to 50 products per failed batch, since the next Sync
2134 // would skip them as "already registered".
2135 }
2136
2137 if ( ! empty( $products_json ) ) {
2138 $data = [
2139 'method' => 'POST',
2140 'blocking' => true,
2141 'headers' => [
2142 'Content-Type' => 'application/json',
2143 'x-wiserrw-api-key' => $api_key,
2144 ],
2145 'body' => wp_json_encode( [
2146 'products' => $products_json,
2147 'ver' => WISERRW_PLUGIN_VERSION // �
2148 Added once at root level
2149 ] ),
2150 ];
2151 $url = $api_host . 'productWebhook?wsid=' . $wsid . '&key=' . $api_key;
2152 $response = wp_safe_remote_post( $url, $data );
2153
2154 // Only mark products as registered if the backend confirmed receipt.
2155 // Failed batches stay unregistered → automatically retried on the
2156 // next Sync click (no Reset needed).
2157 if ( ! is_wp_error( $response ) && (int) wp_remote_retrieve_response_code( $response ) === 200 ) {
2158 foreach ( $products_json as $payload ) {
2159 if ( ! empty( $payload['pid'] ) ) {
2160 update_post_meta( (int) $payload['pid'], '_wiserrw_product_registered', '1' );
2161 }
2162 }
2163 }
2164 }
2165 } else {
2166 $message = 'Finished';
2167 $finished = 1;
2168 }
2169
2170 if ( $page == $total_pages ) {
2171 update_option( 'wiserrw_all_products_synced', 1 );
2172 }
2173
2174 $page++;
2175 $result = [
2176 'next_page' => $page,
2177 'total_pages' => $total_pages,
2178 'message' => $message,
2179 'finished' => $finished,
2180 ];
2181
2182 echo wp_json_encode( $result );
2183 wp_die();
2184 }
2185
2186
2187 add_action( 'wp_ajax_wiserrw_sync_products', 'wiserrw_sync_products' );
2188 add_action( 'wp_ajax_nopriv_wiserrw_sync_products', 'wiserrw_sync_products' );
2189
2190
2191 function wiserrw_reset_products() {
2192 global $wpdb;
2193
2194 check_ajax_referer( 'wiserrw_export_orders_nonce', 'wiserrw_security' );
2195 if ( ! current_user_can( 'manage_woocommerce' ) ) {
2196 wp_send_json_error( array( 'message' => __('You are not allowed to perform this action.','wiser-review' ) ), 403 );
2197 }
2198 // Clear only the "registered" flag for all products
2199 $wpdb->query(
2200 $wpdb->prepare(
2201 "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
2202 '_wiserrw_product_registered'
2203 )
2204 );
2205
2206 // JSON response (unchanged style)
2207 $result = array(
2208 'message' => __( 'Sync status cleared. Click Sync Products to re-sync your product catalog with WiserReview.', 'wiser-review' ),
2209 );
2210 echo wp_json_encode( $result );
2211 wp_die();
2212 }
2213
2214 // Register the AJAX action
2215 add_action( 'wp_ajax_wiserrw_reset_products', 'wiserrw_reset_products' );
2216 add_action( 'wp_ajax_nopriv_wiserrw_reset_products', 'wiserrw_reset_products' );
2217
2218
2219
2220 function wiserrw_product_delete_hook( $p_id ) {
2221 if ( get_post_type( $p_id ) === 'product' ) {
2222 $wiserrw_api_settings = get_option( 'wiserrw_api_settings' );
2223 $api_host = WISERRW_API_HOST;
2224 $api_key = $wiserrw_api_settings['wiserrw_api_key'];
2225 $wsid = get_option( 'wiserrw_wsid', true );
2226 $automation_id = get_option( 'wiserrw_automation_id', true );
2227 if (!isset($wsid) || !isset($automation_id)) {
2228 return; // Exit if invalid API data
2229 }
2230 $url = $api_host . 'productWebhook/'.$p_id.'?wsid='.$wsid.'&key='.$api_key;
2231 $data = array(
2232 'method' => 'DELETE',
2233 'headers' => array(
2234 'x-wiserrw-api-key' => $api_key,
2235 ),
2236 );
2237 $response = wp_remote_request( $url, $data );
2238 }
2239
2240 }
2241 add_action( 'deleted_post', 'wiserrw_product_delete_hook' );
2242 add_action( 'trashed_post', 'wiserrw_product_delete_hook' );
2243
2244 /* -------------------- Product Duplicate Hook -------------------- */
2245
2246 /**
2247 * Strip WiserReview-specific cached data when WooCommerce duplicates a
2248 * product. Without this, "Duplicate" copies all postmeta from the source
2249 * product including:
2250 *
2251 * - wiserrw_star_rating_html / wiserrw_product_html / wiserrw_schema_json
2252 * → the new product would visibly show the source's review count
2253 * and aggregate rating to shoppers and to Google.
2254 *
2255 * - _wiserrw_product_registered → bulk Sync would skip the duplicate
2256 * thinking it's already on the backend, so the new product never
2257 * gets registered.
2258 *
2259 * - _wiserrw_watch_hash → the per-save hook compares against this old
2260 * hash, sees "no change", and never pushes the duplicate to the
2261 * WiserReview backend even after the merchant edits and saves it.
2262 *
2263 * Excluding these keys at copy-time is the canonical WC fix. The
2264 * duplicate then behaves like a brand-new product: empty rating cache
2265 * until reviews come in, fresh push to backend on next save.
2266 *
2267 * @param array $exclude_meta Existing list of meta keys WC will skip.
2268 * @return array Updated exclusion list.
2269 */
2270 add_filter( 'woocommerce_duplicate_product_exclude_meta', 'wiserrw_exclude_meta_on_duplicate' );
2271 function wiserrw_exclude_meta_on_duplicate( $exclude_meta ) {
2272 return array_merge( (array) $exclude_meta, array(
2273 'wiserrw_star_rating_html',
2274 'wiserrw_product_html',
2275 'wiserrw_schema_json',
2276 '_wiserrw_product_registered',
2277 '_wiserrw_watch_hash',
2278 ) );
2279 }
2280
2281 /* -------------------- Product Update Hook -------------------- */
2282
2283 // Only send when name / slug / image / SKU / barcode changed.
2284
2285
2286 // Build the watch-hash from the product's currently-watched fields. Used
2287 // (a) synchronously inside wiserrw_product_update_hook() to skip enqueueing
2288 // jobs that wouldn't change anything, and (b) again inside the async handler
2289 // as a safety check against state drift between enqueue and execution.
2290 function wiserrw_compute_watch_hash( $product ) {
2291 if ( ! $product instanceof WC_Product ) {
2292 return '';
2293 }
2294
2295 $p_id = $product->get_id();
2296 $is_variation = $product instanceof WC_Product_Variation;
2297 $parent_id = $is_variation ? $product->get_parent_id() : $p_id;
2298
2299 $name = (string) $product->get_name();
2300 $permalink = (string) get_permalink( $is_variation ? $parent_id : $p_id );
2301
2302 $effective_image_id = (int) $product->get_image_id();
2303 if ( ! $effective_image_id ) {
2304 $effective_image_id = (int) get_post_thumbnail_id( $is_variation ? $parent_id : $p_id );
2305 }
2306 $image_id = (string) $effective_image_id;
2307
2308 if ( $product->is_type( 'variable' ) && ! $is_variation ) {
2309 $child_skus = array();
2310 foreach ( $product->get_children() as $vid ) {
2311 $vsku = (string) get_post_meta( $vid, '_sku', true );
2312 if ( $vsku !== '' ) {
2313 $child_skus[] = $vsku;
2314 }
2315 }
2316 sort( $child_skus, SORT_NATURAL | SORT_FLAG_CASE );
2317 $sku_fingerprint = implode( ',', $child_skus );
2318 } else {
2319 $sku_fingerprint = (string) $product->get_sku();
2320 }
2321
2322 if ( $is_variation ) {
2323 $barcode = get_post_meta( $p_id, 'hwp_var_gtin', true )
2324 ?: get_post_meta( $parent_id, 'hwp_product_gtin', true )
2325 ?: $product->get_attribute( 'GTIN' )
2326 ?: $product->get_attribute( 'EAN' )
2327 ?: $product->get_attribute( 'ISBN' )
2328 ?: get_post_meta( $p_id, '_global_unique_id', true );
2329 } else {
2330 $barcode = get_post_meta( $p_id, 'hwp_product_gtin', true )
2331 ?: $product->get_attribute( 'GTIN' )
2332 ?: $product->get_attribute( 'EAN' )
2333 ?: $product->get_attribute( 'ISBN' )
2334 ?: get_post_meta( $p_id, '_global_unique_id', true );
2335 }
2336 $barcode = (string) $barcode;
2337
2338 $brand = wiserrw_get_product_brand( $is_variation ? $parent_id : $p_id );
2339
2340 return md5( $name . '|' . $permalink . '|' . $image_id . '|' . $sku_fingerprint . '|' . $barcode . '|' . $brand );
2341 }
2342
2343 add_action( 'woocommerce_after_product_object_save', 'wiserrw_product_update_hook', 99, 1 );
2344
2345 function wiserrw_product_update_hook( $product ) {
2346 if ( ! $product instanceof WC_Product ) {
2347 return;
2348 }
2349
2350 $p_id = $product->get_id();
2351
2352 // Skip autosaves / revisions
2353 if ( wp_is_post_autosave( $p_id ) || wp_is_post_revision( $p_id ) ) {
2354 return;
2355 }
2356
2357 // Pre-enqueue hash check: if no watched field changed, don't queue a job
2358 // at all. Hash computation is cheap (meta reads + string concat, no HTTP)
2359 // — adds ~1–2ms to the save but prevents bulk imports / WPML / ERP syncs
2360 // from flooding Action Scheduler with thousands of no-op jobs.
2361 $new_hash = wiserrw_compute_watch_hash( $product );
2362 $old_hash = (string) get_post_meta( $p_id, '_wiserrw_watch_hash', true );
2363 if ( $new_hash !== '' && $new_hash === $old_hash ) {
2364 return;
2365 }
2366
2367 // Defer the watch-hash check, payload build, and outbound productWebhook
2368 // POST to a background job so the WC product save POST is not held up by
2369 // the remote HTTP call. The handler re-fetches the product, so it always
2370 // works against the latest persisted state at job-run time.
2371 $args = array( (string) $p_id );
2372 if ( function_exists( 'as_enqueue_async_action' ) ) {
2373 // Dedup: skip if a job for this exact pid is already pending.
2374 // Avoids piling up identical jobs on bulk edits / imports / WPML
2375 // translations re-saving the same product in quick succession.
2376 if ( ! as_has_scheduled_action( 'wiserrw_async_product_update', $args, 'wiserreview' ) ) {
2377 as_enqueue_async_action( 'wiserrw_async_product_update', $args, 'wiserreview' );
2378 }
2379 } else {
2380 wp_schedule_single_event( time() + 1, 'wiserrw_async_product_update', $args );
2381 }
2382 }
2383
2384 add_action( 'wiserrw_async_product_update', 'wiserrw_async_product_update_handler', 10, 1 );
2385
2386 function wiserrw_async_product_update_handler( $p_id ) {
2387 $product = wc_get_product( $p_id );
2388 if ( ! $product instanceof WC_Product ) {
2389 return;
2390 }
2391
2392 $is_variation = $product instanceof WC_Product_Variation;
2393 $parent_id = $is_variation ? $product->get_parent_id() : $p_id;
2394
2395 // --- Current values we watch ---
2396 $name = (string) $product->get_name();
2397 $permalink = (string) get_permalink( $is_variation ? $parent_id : $p_id );
2398
2399 // Effective image id (variation image, else parent/simple image)
2400 $effective_image_id = (int) $product->get_image_id();
2401 if ( ! $effective_image_id ) {
2402 $effective_image_id = (int) get_post_thumbnail_id( $is_variation ? $parent_id : $p_id );
2403 }
2404 $image_id = (string) $effective_image_id;
2405
2406 // SKU fingerprint
2407 if ( $product->is_type( 'variable' ) && ! $is_variation ) {
2408 $child_skus = array();
2409 foreach ( $product->get_children() as $vid ) {
2410 $vsku = (string) get_post_meta( $vid, '_sku', true );
2411 if ( $vsku !== '' ) {
2412 $child_skus[] = $vsku;
2413 }
2414 }
2415 sort( $child_skus, SORT_NATURAL | SORT_FLAG_CASE );
2416 $sku_fingerprint = implode( ',', $child_skus );
2417 } else {
2418 $sku_fingerprint = (string) $product->get_sku();
2419 }
2420
2421 // Barcode
2422 if ( $is_variation ) {
2423 $barcode = get_post_meta( $p_id, 'hwp_var_gtin', true )
2424 ?: get_post_meta( $parent_id, 'hwp_product_gtin', true )
2425 ?: $product->get_attribute( 'GTIN' )
2426 ?: $product->get_attribute( 'EAN' )
2427 ?: $product->get_attribute( 'ISBN' )
2428 ?: get_post_meta( $p_id, '_global_unique_id', true );
2429 } else {
2430 $barcode = get_post_meta( $p_id, 'hwp_product_gtin', true )
2431 ?: $product->get_attribute( 'GTIN' )
2432 ?: $product->get_attribute( 'EAN' )
2433 ?: $product->get_attribute( 'ISBN' )
2434 ?: get_post_meta( $p_id, '_global_unique_id', true );
2435 }
2436 $barcode = (string) $barcode;
2437
2438 // Brand (detected from taxonomies, attributes, or meta)
2439 $brand = wiserrw_get_product_brand( $is_variation ? $parent_id : $p_id );
2440
2441 // Watch hash
2442 $new_hash = md5( $name . '|' . $permalink . '|' . $image_id . '|' . $sku_fingerprint . '|' . $barcode . '|' . $brand );
2443 $old_hash = (string) get_post_meta( $p_id, '_wiserrw_watch_hash', true );
2444 if ( $new_hash === $old_hash ) {
2445 return;
2446 }
2447
2448 // --- API settings ---
2449 $wiserrw_api_settings = get_option( 'wiserrw_api_settings' );
2450 if ( empty( $wiserrw_api_settings['wiserrw_api_key'] ) ) {
2451 return;
2452 }
2453 $api_host = WISERRW_API_HOST;
2454 $api_key = $wiserrw_api_settings['wiserrw_api_key'];
2455 $wsid = get_option( 'wiserrw_wsid', true );
2456 $automation_id = get_option( 'wiserrw_automation_id', true );
2457 if ( ! $wsid || ! $automation_id ) {
2458 return;
2459 }
2460
2461 // arrsku
2462 $arrsku = array();
2463 if ( $product->is_type( 'variable' ) && ! $is_variation ) {
2464 $parent_sku = (string) $product->get_sku();
2465 if ( $parent_sku !== '' ) {
2466 $arrsku[] = $parent_sku;
2467 }
2468 foreach ( $product->get_children() as $vid ) {
2469 $vsku = (string) get_post_meta( $vid, '_sku', true );
2470 if ( $vsku !== '' ) {
2471 $arrsku[] = $vsku;
2472 }
2473 }
2474 } else {
2475 if ( $sku_fingerprint !== '' ) {
2476 $arrsku[] = $sku_fingerprint;
2477 }
2478 }
2479
2480 // Image URL
2481 $thumb_id = $effective_image_id;
2482 $img_src = $thumb_id ? wp_get_attachment_image_src( $thumb_id, 'shop_single' ) : false;
2483 $img_url = ( is_array( $img_src ) && isset( $img_src[0] ) ) ? $img_src[0] : wc_placeholder_img_src( 'shop_single' );
2484
2485 // --- Multilingual handling (Added) ---
2486 $original_pid = 0;
2487 $current_lang = '';
2488
2489 // WPML
2490 if ( has_filter( 'wpml_element_trid' ) ) {
2491 $trid = apply_filters( 'wpml_element_trid', null, $p_id, 'post_product' );
2492 $translations = $trid ? apply_filters( 'wpml_get_element_translations', null, $trid, 'post_product' ) : array();
2493 if ( $translations && is_array( $translations ) ) {
2494 foreach ( $translations as $lang => $translation ) {
2495 if ( ! empty( $translation->original ) ) {
2496 $original_pid = (int) $translation->element_id;
2497 }
2498 if ( (int) $translation->element_id === $p_id ) {
2499 $current_lang = $lang;
2500 }
2501 }
2502 }
2503 }
2504
2505 // Polylang
2506 if ( function_exists( 'pll_get_post_language' ) ) {
2507 $current_lang = pll_get_post_language( $p_id );
2508 if ( function_exists( 'pll_default_language' ) ) {
2509 $default_lang = pll_default_language();
2510 if ( $current_lang && $default_lang && $current_lang !== $default_lang ) {
2511 $original_pid = pll_get_post( $p_id, $default_lang );
2512 }
2513 }
2514 }
2515
2516 // Fallbacks
2517 $opid_value = ( $original_pid && $original_pid !== $p_id ) ? $original_pid : $p_id;
2518
2519 // --- Build product payload ---
2520 $product_json = array(
2521 'pid' => (string) $p_id,
2522 'arrsku' => $arrsku,
2523 'shp' => get_site_url(),
2524 'wsid' => $wsid,
2525 'pn' => $name ?: get_the_title( $parent_id ),
2526 'piu' => $img_url,
2527 'pu' => $permalink,
2528 'barcode' => $barcode,
2529 'brand' => (string) $brand,
2530 'opid' => $opid_value,
2531 'lang' => wiserrw_normalize_lang( $current_lang ),
2532 );
2533
2534 $args = array(
2535 'method' => 'POST',
2536 'blocking' => true,
2537 'headers' => array(
2538 'Content-Type' => 'application/json',
2539 'x-wiserrw-api-key' => $api_key,
2540 ),
2541 'body' => wp_json_encode( array(
2542 'products' => array( $product_json ),
2543 'ver' => WISERRW_PLUGIN_VERSION
2544 ) ),
2545 );
2546
2547 // Clear flag before send
2548 delete_post_meta( $p_id, '_wiserrw_product_registered' );
2549
2550 $response = wp_safe_remote_post( $api_host . 'productWebhook?wsid=' . $wsid . '&key=' . $api_key, $args );
2551 if ( is_wp_error( $response ) ) {
2552 return;
2553 }
2554
2555 if ( (int) wp_remote_retrieve_response_code( $response ) === 200 ) {
2556 update_post_meta( $p_id, '_wiserrw_product_registered', '1' );
2557 update_post_meta( $p_id, '_wiserrw_watch_hash', $new_hash );
2558 }
2559 }
2560
2561
2562
2563 function wiserrw_custom_endpoints() {
2564 register_rest_route(
2565 'wiserrw/v1',
2566 '/reviews',
2567 array(
2568 array(
2569 'methods' => 'POST',
2570 'permission_callback' => 'wiserrw_api_auth',
2571 'callback' => 'wiserrw_api_callback',
2572 'args' => array(),
2573 )
2574 )
2575 );
2576 }
2577 add_action( 'rest_api_init', 'wiserrw_custom_endpoints' );
2578
2579
2580 function wiserrw_api_auth( WP_REST_Request $request ) {
2581 $provided = $request->get_header( 'wiserrw-wsid' );
2582 if ( ! $provided ) {
2583 $provided = $request->get_param( 'wiserrw_wsid' );
2584 }
2585
2586 if ( ! is_string( $provided ) || $provided === '' ) {
2587 return new WP_Error(
2588 'wiserrw_auth_missing',
2589 'Missing API key. Send it in the API Key header or api_key.',
2590 array( 'status' => 401 )
2591 );
2592 }
2593
2594 $wiserrw_api_settings = get_option( 'wiserrw_api_settings', array() );
2595 $wiserrw_wsid = get_option( 'wiserrw_wsid', true );
2596 if ($wiserrw_wsid == '') {
2597 return new WP_Error(
2598 'wiserrw_auth_config',
2599 'API key not configured.',
2600 array( 'status' => 500 )
2601 );
2602 }
2603
2604 $api_key = $wiserrw_wsid;
2605 $valid_keys = array($api_key);
2606
2607 foreach ( $valid_keys as $valid ) {
2608 if ( hash_equals( $valid, $provided ) ) {
2609 return true;
2610 }
2611 } return new WP_Error(
2612 'wiserrw_auth_missing',
2613 'Invalid API key.',
2614 array( 'status' => 401 )
2615 );
2616 }
2617
2618
2619
2620 // === MAIN API ENDPOINT (returns instantly) ===
2621 function wiserrw_api_callback( WP_REST_Request $request ) {
2622
2623 $payload = array(
2624 'status' => 'ok',
2625 'method' => $request->get_method(),
2626 'time' => current_time( 'mysql', true ),
2627 'data' => $request->get_json_params() ?: $request->get_params(),
2628 );
2629
2630 // Fire background call to self (non-blocking).
2631 // The /async endpoint runs the heavy work (css/html sync, AS
2632 // enqueues for schema) in a separate PHP-FPM worker, so the
2633 // visitor's pixel request returns immediately on healthy stacks.
2634 wp_remote_post(
2635 rest_url( 'wiserreview/v1/async' ),
2636 array(
2637 'blocking' => false,
2638 'timeout' => 3,
2639 'body' => array(
2640 'json_data' => wp_json_encode( $payload ),
2641 ),
2642 )
2643 );
2644
2645 // Return immediately
2646 return new WP_REST_Response( $payload, 200 );
2647 }
2648 add_action( 'rest_api_init', function() {
2649 register_rest_route( 'wiserreview/v1', '/api', array(
2650 'methods' => 'POST',
2651 'callback' => 'wiserrw_api_callback',
2652 'permission_callback' => '__return_true',
2653 ));
2654 });
2655
2656 // === ASYNC BACKGROUND HANDLER ===
2657 add_action( 'rest_api_init', function() {
2658 register_rest_route( 'wiserreview/v1', '/async', array(
2659 'methods' => 'POST',
2660 'callback' => 'wiserrw_async_handler',
2661 'permission_callback' => '__return_true',
2662 ));
2663 });
2664
2665 function wiserrw_async_handler( WP_REST_Request $request ) {
2666
2667 $json_data = $request->get_param( 'json_data' );
2668 if ( empty( $json_data ) ) {
2669 return new WP_REST_Response( array( 'error' => 'No data received' ), 400 );
2670 }
2671
2672 $payload = json_decode( $json_data, true );
2673 if ( empty( $payload['data'] ) || ! is_array( $payload['data'] ) ) {
2674 return new WP_REST_Response( array( 'error' => 'Invalid payload' ), 400 );
2675 }
2676
2677 // Build a fake REST request to reuse your existing logic
2678 $fake_request = new WP_REST_Request( 'POST', '/wiserreview/v1/api' );
2679
2680 foreach ( $payload['data'] as $key => $value ) {
2681 $fake_request->set_param( $key, $value );
2682 }
2683
2684 // Force JSON body so get_json_params() works inside callback
2685 $fake_request->set_body( wp_json_encode( $payload['data'] ) );
2686 $fake_request->set_header( 'Content-Type', 'application/json' );
2687
2688 // Run your main process in background
2689 do_action( 'wiserrw_endpoint_called', $fake_request );
2690
2691 return new WP_REST_Response( array( 'status' => 'background task started' ), 200 );
2692 }
2693
2694 // === MAIN LOGIC HANDLER ===
2695 function wiserrw_api_response_callback( $request ) {
2696
2697 // --- Safely parse JSON and normal params ---
2698 $raw = $request->get_body();
2699 $data = json_decode( $raw, true );
2700
2701 // Fallback if JSON body not parsed
2702 if ( empty( $data ) || ! is_array( $data ) ) {
2703 $data = $request->get_json_params() ?: $request->get_params();
2704 }
2705
2706 if ( empty( $data ) || ! is_array( $data ) ) {
2707 return;
2708 }
2709 // �
2710 CHANGED: read everything from body, not from query
2711 $wsid_param = $data['wsid'] ?? '';
2712 $verify_api = $data['verifyApi'] ?? '';
2713 $updtyp = $data['updtyp'] ?? '';
2714 $widget_id = $data['widgetId'] ?? '';
2715
2716 // Current workspace ID saved in site
2717 $wsid = get_option( 'wiserrw_wsid', '' );
2718
2719 switch ( $updtyp ) {
2720
2721 // --- CSS update ---
2722 case 'update_css':
2723 // �
2724 CHANGED: verifyApi now read from body (string "true")
2725 $is_verified = filter_var( $verify_api, FILTER_VALIDATE_BOOLEAN );
2726
2727 if ( $is_verified && $wsid !== '' && $wsid === $wsid_param ) {
2728 $wiserrw_api_settings = get_option( 'wiserrw_api_settings', array() );
2729 $api_key = $wiserrw_api_settings['wiserrw_api_key'] ?? '';
2730
2731 // Validate API and update CSS
2732 wiserrw_validate_api( $api_key );
2733 wiserrw_update_css( $wsid );
2734 }
2735 break;
2736
2737 // --- HTML update (clear widget cache) ---
2738 case 'update_html':
2739 if ( ! empty( $widget_id ) ) {
2740 $option_key = 'wiserreview_widget_' . $widget_id;
2741 delete_option( $option_key );
2742 wiserrw_update_html( $wsid, $widget_id );
2743 }
2744 break;
2745
2746 // --- Schema generation and async remote update ---
2747 case 'update_schema':
2748 if ( ! empty( $data['arrPid'] ) && is_array( $data['arrPid'] ) ) {
2749 $wsid_to_use = $data['wsid'] ?? $wsid;
2750 $async_api_settings = get_option( 'wiserrw_api_settings', array() );
2751 $async_api_key = $async_api_settings['wiserrw_api_key'] ?? '';
2752
2753 foreach ( $data['arrPid'] as $p_id ) {
2754 if ( ! empty( $p_id ) && is_numeric( $p_id ) ) {
2755
2756 // Enqueue one async job per product so each pid runs in
2757 // its own short-lived background worker. Avoids holding
2758 // one PHP-FPM worker hostage for the whole arrPid loop
2759 // (which would slow down concurrent WC admin/frontend
2760 // requests) and survives max_execution_time.
2761 $args = array( (string) $p_id, $wsid_to_use, $async_api_key );
2762
2763 if ( function_exists( 'as_enqueue_async_action' ) ) {
2764 // Dedup: skip if a job for this exact pid (with same
2765 // wsid + api key) is already pending. Note: args must
2766 // match exactly — if the API key rotates between
2767 // calls, the old pending job will not be deduped.
2768 if ( ! as_has_scheduled_action( 'wiserrw_async_schema_update', $args, 'wiserreview' ) ) {
2769 as_enqueue_async_action( 'wiserrw_async_schema_update', $args, 'wiserreview' );
2770 }
2771 } else {
2772 wp_schedule_single_event( time() + 1, 'wiserrw_async_schema_update', $args );
2773 }
2774 }
2775 }
2776 }
2777 break;
2778
2779 default:
2780 // No action for unknown updtyp
2781 break;
2782 }
2783 }
2784 add_action( 'wiserrw_endpoint_called', 'wiserrw_api_response_callback' );
2785
2786 add_action( 'wiserrw_async_schema_update', 'wiserrw_async_schema_update_handler', 10, 3 );
2787
2788 function wiserrw_async_schema_update_handler( $p_id, $wsid, $api_key ) {
2789 if ( empty( $p_id ) || ! is_numeric( $p_id ) ) {
2790 return;
2791 }
2792 $p_id = (string) $p_id;
2793
2794 // Run schema generation now (needs WP context)
2795 wiserrw_generate_schema( $p_id );
2796
2797 // Send async schema update (non-blocking)
2798 wp_remote_post(
2799 WISERRW_RS_HOST . '/api/wp/updSchema',
2800 array(
2801 'method' => 'POST',
2802 'headers' => array(
2803 'Content-Type' => 'application/json',
2804 'x-wiserrw-api-key' => $api_key,
2805 ),
2806 'body' => wp_json_encode( array(
2807 'wsid' => $wsid,
2808 'pid' => $p_id,
2809 'updtyp' => 'update_schema',
2810 'ver' => WISERRW_PLUGIN_VERSION,
2811 )),
2812 'blocking' => false,
2813 'timeout' => 3,
2814 )
2815 );
2816 }
2817
2818 function wiserrw_update_css( $wsid ) {
2819 $url = WISERRW_RS_HOST . '/api/wp/updSchema';
2820 $wiserrw_api_settings = get_option( 'wiserrw_api_settings', array() );
2821 $api_key = $wiserrw_api_settings['wiserrw_api_key'] ?? '';
2822 $body = array(
2823 'wsid' => $wsid,
2824 'updtyp' => 'update_css',
2825 'ver' => WISERRW_PLUGIN_VERSION
2826 );
2827 $response = wp_remote_post(
2828 $url,
2829 array(
2830 'method' => 'POST',
2831 'headers' => array(
2832 'Content-Type' => 'application/json',
2833 'x-wiserrw-api-key' => $api_key,
2834 ),
2835 'body' => wp_json_encode( $body ),
2836 'timeout' => 30,
2837 )
2838 );
2839 }
2840
2841 function wiserrw_update_schema( $wsid, $pid ) {
2842 $url = WISERRW_RS_HOST . '/api/wp/updSchema';
2843 $wiserrw_api_settings = get_option( 'wiserrw_api_settings', array() );
2844 $api_key = $wiserrw_api_settings['wiserrw_api_key'] ?? '';
2845 $body = array(
2846 'wsid' => $wsid,
2847 'pid' => $pid,
2848 'updtyp' => 'update_schema',
2849 'ver' => WISERRW_PLUGIN_VERSION
2850 );
2851 $response = wp_remote_post(
2852 $url,
2853 array(
2854 'method' => 'POST',
2855 'headers' => array(
2856 'Content-Type' => 'application/json',
2857 'x-wiserrw-api-key' => $api_key,
2858 ),
2859 'body' => wp_json_encode( $body ),
2860 'timeout' => 30,
2861 )
2862 );
2863 }
2864
2865 function wiserrw_update_html( $wsid, $widget_id ) {
2866 $url = WISERRW_RS_HOST . '/api/wp/updSchema';
2867 $wiserrw_api_settings = get_option( 'wiserrw_api_settings', array() );
2868 $api_key = $wiserrw_api_settings['wiserrw_api_key'] ?? '';
2869 $body = array(
2870 'wsid' => $wsid,
2871 'widgetId' => $widget_id,
2872 'updtyp' => 'update_html',
2873 'ver' => WISERRW_PLUGIN_VERSION
2874 );
2875 $response = wp_remote_post(
2876 $url,
2877 array(
2878 'method' => 'POST',
2879 'headers' => array(
2880 'Content-Type' => 'application/json',
2881 'x-wiserrw-api-key' => $api_key,
2882 ),
2883 'body' => wp_json_encode( $body ),
2884 'timeout' => 30,
2885 )
2886 );
2887 }
2888
2889
2890 function wiserrw_get_lang_context( $product_id ) {
2891 $ctx = array(
2892 'original_pid' => 0,
2893 'current_pid' => (int) $product_id,
2894 'lang' => '',
2895 );
2896
2897 if ( function_exists( 'pll_get_post' ) ) {
2898 $ctx['original_pid'] = (int) pll_get_post( $product_id, pll_default_language() );
2899 $ctx['lang'] = (string) pll_get_post_language( $product_id );
2900 return $ctx;
2901 }
2902
2903 if ( has_filter( 'wpml_object_id' ) ) {
2904 $trid = apply_filters( 'wpml_element_trid', null, $product_id, 'post_product' );
2905 $translations = $trid ? apply_filters( 'wpml_get_element_translations', null, $trid, 'post_product' ) : array();
2906 if ( is_array( $translations ) ) {
2907 foreach ( $translations as $lang => $t ) {
2908 $eid = is_object($t) ? (int)$t->element_id : (int)($t['element_id'] ?? 0);
2909 if ( ! empty( $t->original ) || ( is_array($t) && ! empty($t['original']) ) ) {
2910 $ctx['original_pid'] = $eid;
2911 }
2912 if ( $eid === (int) $product_id ) {
2913 $ctx['lang'] = $lang;
2914 }
2915 }
2916 }
2917 }
2918
2919 return $ctx;
2920 }
2921
2922 function wiserrw_generate_schema( $p_id ) {
2923 if ( empty( $p_id ) || ! is_numeric( $p_id ) ) {
2924 return;
2925 }
2926
2927 // --- Multilingual handling (WPML + Polylang safe) ---
2928 $original_pid = 0;
2929 $current_lang = '';
2930
2931 // WPML
2932 if ( has_filter( 'wpml_element_trid' ) ) {
2933 $trid = apply_filters( 'wpml_element_trid', null, $p_id, 'post_product' );
2934 $translations = $trid ? apply_filters( 'wpml_get_element_translations', null, $trid, 'post_product' ) : array();
2935 $current_lang = apply_filters( 'wpml_current_language', null );
2936 if ( $translations && is_array( $translations ) ) {
2937 foreach ( $translations as $lang => $translation ) {
2938 if ( ! empty( $translation->original ) ) {
2939 $original_pid = (int) $translation->element_id;
2940 }
2941 }
2942 }
2943 }
2944
2945 // Polylang
2946 if ( function_exists( 'pll_get_post' ) && function_exists( 'pll_current_language' ) ) {
2947 $current_lang = pll_current_language();
2948 $default_lang = pll_default_language();
2949 if ( $current_lang && $default_lang && $current_lang !== $default_lang ) {
2950 $original_pid = pll_get_post( $p_id, $default_lang );
2951 }
2952 }
2953
2954 $opid_value = ( $original_pid && $original_pid !== $p_id ) ? $original_pid : $p_id;
2955 $lang_value = wiserrw_normalize_lang( $current_lang );
2956
2957 // --- API settings ---
2958 $wiserrw_api_settings = get_option( 'wiserrw_api_settings', array() );
2959 if ( ! is_array( $wiserrw_api_settings ) || empty( $wiserrw_api_settings['wiserrw_api_key'] ) ) {
2960 return;
2961 }
2962
2963 $wsid = get_option( 'wiserrw_wsid', true );
2964 $automation_id = get_option( 'wiserrw_automation_id', true );
2965 if ( ! $wsid || ! $automation_id ) {
2966 return;
2967 }
2968
2969 $api_key = $wiserrw_api_settings['wiserrw_api_key'];
2970 $url = WISERRW_RS_HOST . '/api/getData';
2971 $body = array(
2972 'arrType' => array( 'main', 'star_rating' ),
2973 'pid' => (string) $p_id,
2974 'opid' => (string) $opid_value,
2975 'lang' => $lang_value,
2976 'arrPid' => array( (string) $p_id ),
2977 'isFrom' => 'woocommerce',
2978 'wsid' => $wsid,
2979 'updtyp' => 'update_schema',
2980 'ver' => WISERRW_PLUGIN_VERSION
2981 );
2982
2983 $response = wp_remote_post(
2984 $url,
2985 array(
2986 'method' => 'POST',
2987 'headers' => array(
2988 'Content-Type' => 'application/json',
2989 'x-wiserrw-api-key' => $api_key,
2990 ),
2991 'body' => wp_json_encode( $body ),
2992 'timeout' => 30,
2993 )
2994 );
2995
2996 if ( is_wp_error( $response ) ) {
2997 return;
2998 }
2999
3000 $json = wp_remote_retrieve_body( $response );
3001 $data = json_decode( $json, true );
3002 if ( empty( $data['data'] ) ) {
3003 return;
3004 }
3005
3006 $schema_array = array();
3007 $reviewCount = 0;
3008
3009 foreach ( $data['data'] as $row ) {
3010 $wdtyp = $row['wdtyp'];
3011
3012 if ( $wdtyp == 'main' ) {
3013 $reviewCount = intval( $row['dataCount']['prtng'] );
3014
3015
3016 $schema_array['@context'] = "https://schema.org";
3017 $schema_array['@type'] = "Product";
3018 $schema_array['@id'] = get_the_permalink( $p_id );
3019 $schema_array['url'] = get_the_permalink( $p_id );
3020 $schema_array['name'] = get_the_title( $p_id );
3021 $schema_array['aggregateRating'] = array(
3022 '@type' => 'AggregateRating',
3023 'ratingValue' => $row['dataCount']['avgrtng'],
3024 'reviewCount' => $reviewCount,
3025 'bestRating' => '5',
3026 'worstRating' => '1',
3027 );
3028 }
3029
3030 if ( $wdtyp == 'star_rating' ) {// && !empty( $row['dataRating'][0]['html'] )
3031 update_post_meta( $p_id, 'wiserrw_star_rating_html', $row['dataRating'][0]['html'] );
3032 }
3033
3034 if ( $wdtyp == 'main' && !empty( $row['htmlForSEO'] ) ) {
3035 update_post_meta( $p_id, 'wiserrw_product_html', $row['htmlForSEO']);
3036 }
3037
3038 if ( ! empty( $row['data'] ) && is_array( $row['data'] ) ) {
3039 foreach ( $row['data'] as $review_row ) {
3040 $schema_array['review'][] = array(
3041 "@type" => "Review",
3042 'author' => array(
3043 '@type' => "Person",
3044 'name' => $review_row['un']
3045 ),
3046 'reviewBody' => $review_row['orgnlrtxt'],
3047 'reviewRating' => array(
3048 '@type' => 'Rating',
3049 'ratingValue' => $review_row['rtng'],
3050 'bestRating' => '5',
3051 'worstRating' => '1',
3052 ),
3053 'publisher' => array(
3054 "@type" => "Organization",
3055 "name" => "WiserReview"
3056 )
3057 );
3058 }
3059 }
3060 }
3061
3062 // �
3063 Save or delete only once, after loop
3064 if ( $reviewCount > 0 ) {
3065 update_post_meta( $p_id, 'wiserrw_schema_json', json_encode( $schema_array, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) );
3066 } else {
3067 delete_post_meta( $p_id, 'wiserrw_schema_json' );
3068 // if($HideZeroStar){
3069 // delete_post_meta( $p_id, 'wiserrw_star_rating_html' );
3070 // }
3071 delete_post_meta( $p_id, 'wiserrw_product_html' );
3072 }
3073
3074 wiserrw_send_product_update_on_save_post( $p_id );
3075 }
3076
3077
3078 /* -------------------------------------------------------------------------
3079 * SEO plugin integration — merge aggregateRating into existing Product
3080 * schema instead of emitting a duplicate <script> block.
3081 *
3082 * Strategy:
3083 * Layer 1 — woocommerce_structured_data_product: WooCommerce core's
3084 * Product schema filter. WC core, AIOSEO, and the no-SEO-plugin
3085 * case funnel through this, so a single callback enriches them.
3086 * Layer 2 — rank_math/snippet/rich_snippet_product_entity: RankMath
3087 * actively removes WC's schema action and emits its own
3088 * Product schema, so we hook RankMath directly.
3089 * Layer 3 — seopress_schemas_auto_product_json: SEOPress Pro emits its
3090 * own Product schema outside WC's pipeline, so we hook it.
3091 * Layer 4 — wpseo_schema_product: Yoast SEO: WooCommerce captures WC's
3092 * product data, returns [] to WC, and prints its own Product /
3093 * ProductGroup graph in the footer (bypassing wpseo_schema_graph).
3094 * wpseo_schema_product is the filter on that node, so we hook it.
3095 * Layer 5 — Standalone <script> output is suppressed below in
3096 * wiserrw_product_schema() when any integration target fired,
3097 * so the page never has duplicate Product nodes.
3098 *
3099 * Performance: zero DB queries beyond a single get_post_meta() (already
3100 * cached by WP's object cache). Static per-request cache. No HTTP. No
3101 * regex on output. Hot path adds <0.1ms per product page render.
3102 * ----------------------------------------------------------------------- */
3103
3104 /**
3105 * Detect which supported SEO plugin is active on this site, if any.
3106 * Used by the integration callbacks (info-only) and by the settings UI
3107 * to show merchants which plugin we're auto-merging schema into.
3108 *
3109 * @return string|null Human-readable plugin name, or null if none detected.
3110 */
3111 function wiserrw_detect_active_seo_plugin() {
3112 if ( defined( 'RANK_MATH_VERSION' ) ) {
3113 return 'RankMath';
3114 }
3115 if ( defined( 'WPSEO_VERSION' ) ) {
3116 return 'Yoast SEO';
3117 }
3118 if ( defined( 'AIOSEO_VERSION' ) || defined( 'AIOSEO_FILE' ) ) {
3119 return 'All in One SEO';
3120 }
3121 if ( defined( 'SEOPRESS_PRO_VERSION' ) ) {
3122 return 'SEOPress Pro';
3123 }
3124 if ( defined( 'SEOPRESS_VERSION' ) ) {
3125 return 'SEOPress';
3126 }
3127 return null;
3128 }
3129
3130 /**
3131 * Pull the aggregateRating + review data we already generated for a
3132 * product. Backed by the existing 'wiserrw_schema_json' post_meta —
3133 * no new storage, no extra API calls. Statically cached per request.
3134 *
3135 * @param int $product_id WooCommerce product post ID.
3136 * @return array|false { aggregateRating: array, review?: array } or
3137 * false when no rating data exists for this product.
3138 */
3139 /**
3140 * Per-request flag — set TRUE the first time one of the Layer 1/2/3/4
3141 * filter callbacks actually injects our aggregateRating into another
3142 * plugin's Product schema. Read at wp_footer (very late) by the
3143 * standalone schema function so it can decide: did integration run?
3144 * - Yes → suppress standalone (would duplicate the Product entity)
3145 * - No → emit standalone (no other plugin emitted Product schema)
3146 *
3147 * This makes suppression observation-based rather than plugin-presence
3148 * based: if SEOPress Pro is installed but its Product schema feature is
3149 * disabled (and no other SEO plugin took over), our filter never fires,
3150 * the flag stays FALSE, and our standalone renders as a fallback.
3151 *
3152 * @param bool|null $set If true, marks integration as fired. Omit to read.
3153 * @return bool Whether integration has fired during this request.
3154 */
3155 function wiserrw_integration_fired( $set = null ) {
3156 static $fired = false;
3157 if ( $set === true ) {
3158 $fired = true;
3159 }
3160 return $fired;
3161 }
3162
3163 function wiserrw_get_rating_for_seo_integration( $product_id ) {
3164 static $cache = array();
3165
3166 $product_id = (int) $product_id;
3167 if ( $product_id <= 0 ) {
3168 return false;
3169 }
3170 if ( array_key_exists( $product_id, $cache ) ) {
3171 return $cache[ $product_id ];
3172 }
3173
3174 $json = get_post_meta( $product_id, 'wiserrw_schema_json', true );
3175 if ( empty( $json ) ) {
3176 return $cache[ $product_id ] = false;
3177 }
3178
3179 $data = json_decode( $json, true );
3180 // Don't inject empty / zero-review aggregateRating — Google flags it
3181 // as invalid structured data.
3182 if ( ! is_array( $data ) || empty( $data['aggregateRating']['reviewCount'] ) ) {
3183 return $cache[ $product_id ] = false;
3184 }
3185
3186 $result = array(
3187 'aggregateRating' => $data['aggregateRating'],
3188 );
3189 if ( ! empty( $data['review'] ) && is_array( $data['review'] ) ) {
3190 $result['review'] = $data['review'];
3191 }
3192
3193 return $cache[ $product_id ] = $result;
3194 }
3195
3196 /**
3197 * Single source of truth for whether WiserReview should output / inject any
3198 * Product schema at all. Gates both the standalone fallback (Layer 5) and the
3199 * SEO-plugin integration callbacks (Layers 1/2/3/4) so the schema toggle behaves
3200 * consistently everywhere: schema OFF means no WiserReview rating in schema,
3201 * regardless of which SEO plugin is active.
3202 */
3203 function wiserrw_schema_output_enabled() {
3204 $s = get_option( 'wiserrw_api_settings', array() );
3205 return isset( $s['wiserrw_live_mode_checkbox'] ) && isset( $s['wiserrw_schema_enabled'] );
3206 }
3207
3208 /**
3209 * Layer 1 — WooCommerce native Product schema (used by WC core, Yoast,
3210 * AIOSEO Pro, SEOPress Pro and any other plugin that builds on top of
3211 * WC's structured data pipeline).
3212 */
3213 add_filter( 'woocommerce_structured_data_product', 'wiserrw_inject_rating_into_wc_schema', 99, 2 );
3214 function wiserrw_inject_rating_into_wc_schema( $markup, $product ) {
3215 if ( ! wiserrw_schema_output_enabled() ) {
3216 return $markup;
3217 }
3218 if ( ! is_array( $markup ) || ! ( $product instanceof WC_Product ) ) {
3219 return $markup;
3220 }
3221 $rating = wiserrw_get_rating_for_seo_integration( $product->get_id() );
3222 if ( empty( $rating ) ) {
3223 return $markup;
3224 }
3225 $markup['aggregateRating'] = $rating['aggregateRating'];
3226 if ( ! empty( $rating['review'] ) ) {
3227 $markup['review'] = $rating['review'];
3228 }
3229 wiserrw_integration_fired( true );
3230 return $markup;
3231 }
3232
3233 /**
3234 * Layer 2 — RankMath's Product schema. RankMath unhooks WC's structured
3235 * data and renders its own, so the WC filter above never fires for
3236 * stores running RankMath. Verified from RankMath source: the filter
3237 * runs in class-singular.php (do_filter('snippet/rich_snippet_' . $type
3238 * . '_entity', $entity)).
3239 */
3240 add_filter( 'rank_math/snippet/rich_snippet_product_entity', 'wiserrw_inject_rating_into_rankmath', 99, 1 );
3241 function wiserrw_inject_rating_into_rankmath( $entity ) {
3242 if ( ! wiserrw_schema_output_enabled() ) {
3243 return $entity;
3244 }
3245 if ( ! is_array( $entity ) ) {
3246 return $entity;
3247 }
3248 $product_id = get_the_ID();
3249 $rating = wiserrw_get_rating_for_seo_integration( $product_id );
3250 if ( empty( $rating ) ) {
3251 return $entity;
3252 }
3253 $entity['aggregateRating'] = $rating['aggregateRating'];
3254 if ( ! empty( $rating['review'] ) ) {
3255 $entity['review'] = $rating['review'];
3256 }
3257 wiserrw_integration_fired( true );
3258 return $entity;
3259 }
3260
3261 /**
3262 * Layer 3 — SEOPress Pro's Product schema. SEOPress Pro emits its own
3263 * Product schema independently of WC's pipeline (and frequently removes
3264 * WC's native schema via remove_action when its "Disable WC schema"
3265 * setting is enabled), so neither Layer 1 nor Layer 2 catches it.
3266 *
3267 * SEOPress Pro reads aggregateRating from WooCommerce's native review
3268 * system (wp_comments + meta_key='rating'). WiserReview stores reviews
3269 * on its own backend, NOT in wp_comments — so on a WR merchant's site
3270 * SEOPress Pro emits Product schema with NO aggregateRating and at most
3271 * one rating-only review. We inject our real reviews here.
3272 *
3273 * Verified from SEOPress Pro v9.8.4 source:
3274 * inc/functions/schemas/Product.php:315
3275 * $json = apply_filters( 'seopress_schemas_auto_product_json', $json );
3276 */
3277 add_filter( 'seopress_schemas_auto_product_json', 'wiserrw_inject_rating_into_seopress', 99, 1 );
3278 function wiserrw_inject_rating_into_seopress( $json ) {
3279 if ( ! wiserrw_schema_output_enabled() ) {
3280 return $json;
3281 }
3282 if ( ! is_array( $json ) ) {
3283 return $json;
3284 }
3285 $product_id = get_the_ID();
3286 $rating = wiserrw_get_rating_for_seo_integration( $product_id );
3287 if ( empty( $rating ) ) {
3288 return $json;
3289 }
3290 $json['aggregateRating'] = $rating['aggregateRating'];
3291 if ( ! empty( $rating['review'] ) ) {
3292 $json['review'] = $rating['review'];
3293 }
3294 wiserrw_integration_fired( true );
3295 return $json;
3296 }
3297
3298 /**
3299 * Layer 4 — Yoast SEO: WooCommerce. The WC addon does NOT route product schema
3300 * through Yoast's main graph filter (wpseo_schema_graph). Instead
3301 * WPSEO_WooCommerce_Schema::change_product() captures WC's product data via the
3302 * woocommerce_structured_data_product filter, returns [] to WooCommerce (so WC
3303 * emits nothing), and prints its own graph in the footer via
3304 * output_schema_footer() — which echoes the data directly, bypassing
3305 * wpseo_schema_graph entirely. That is why hooking the graph filter never
3306 * touched the ProductGroup.
3307 *
3308 * The one filter applied to the final node is wpseo_schema_product, run at the
3309 * end of change_product() right before the data is stored for footer output.
3310 * Verified from Yoast SEO: WooCommerce v16.8 source (class-schema.php):
3311 * $this->data = apply_filters( 'wpseo_schema_product', $this->data );
3312 *
3313 * $data is the single Product node (simple products) or ProductGroup node
3314 * (variable products — Yoast sets @type = 'ProductGroup' in filter_variations()).
3315 * aggregateRating is valid on both; on ProductGroup it represents the rating
3316 * across all variants, matching our parent-product review model.
3317 *
3318 * The filter passes no product object, so we resolve the id from the queried
3319 * object (always the main product on a single-product page).
3320 */
3321 add_filter( 'wpseo_schema_product', 'wiserrw_inject_rating_into_yoast', 11, 1 );
3322 function wiserrw_inject_rating_into_yoast( $data ) {
3323 if ( ! is_array( $data ) || ! wiserrw_schema_output_enabled() ) {
3324 return $data;
3325 }
3326 $product_id = (int) get_queried_object_id();
3327 if ( ! $product_id && function_exists( 'get_the_ID' ) ) {
3328 $product_id = (int) get_the_ID();
3329 }
3330 $rating = wiserrw_get_rating_for_seo_integration( $product_id );
3331 if ( empty( $rating ) ) {
3332 return $data;
3333 }
3334 $data['aggregateRating'] = $rating['aggregateRating'];
3335 if ( ! empty( $rating['review'] ) ) {
3336 $data['review'] = $rating['review'];
3337 }
3338 wiserrw_integration_fired( true );
3339 return $data;
3340 }
3341
3342
3343 // Standalone Product schema. Runs at the very end of wp_footer so we can
3344 // observe whether any of the Layer 1/2/3/4 integration callbacks fired
3345 // during this request — if any did, the page already has Product schema
3346 // with our reviews and we skip the standalone to avoid duplicates.
3347 // If none fired (no SEO plugin emitted Product schema, or their schema
3348 // feature is disabled, or WC's native schema was removed without
3349 // replacement), the standalone renders as a fallback so the page is
3350 // never left without a Product entity.
3351 add_action( 'wp_footer', 'wiserrw_product_schema', 9999 );
3352 function wiserrw_product_schema() {
3353 if ( ! is_product() ) {
3354 return;
3355 }
3356
3357 // Gated by live mode + schema toggle — same check as the Layer 1/2/3/4
3358 // integration callbacks, so the toggle is authoritative everywhere.
3359 if ( ! wiserrw_schema_output_enabled() ) {
3360 return;
3361 }
3362
3363 // Layer 5 — Suppress standalone IF integration actually fired.
3364 //
3365 // wiserrw_integration_fired() returns true only when one of the
3366 // Layer 1/2/3/4 filter callbacks (WC native, RankMath, SEOPress Pro,
3367 // Yoast SEO: WooCommerce) ran during this request — meaning another
3368 // plugin's Product schema was generated and we successfully injected
3369 // our aggregateRating into it. In that case emitting our standalone
3370 // would duplicate.
3371 //
3372 // If integration didn't fire — e.g., merchant has RankMath installed
3373 // but disabled its Schema module, or SEOPress Pro installed but
3374 // disabled Product schema, or a perf plugin removed WC's native
3375 // schema with no SEO plugin replacing it — the flag stays false and
3376 // our standalone renders as the only Product schema on the page.
3377 //
3378 // The 'wiserrw_suppress_standalone_schema' filter remains available
3379 // for merchants who want explicit control.
3380 $integration_active = wiserrw_integration_fired();
3381
3382 if ( apply_filters( 'wiserrw_suppress_standalone_schema', $integration_active ) ) {
3383 return;
3384 }
3385
3386 $p_id = get_queried_object_id();
3387 if ( ! $p_id ) {
3388 return;
3389 }
3390
3391 $wiserrw_schema_json = get_post_meta( $p_id, 'wiserrw_schema_json', true );
3392
3393 if ( $wiserrw_schema_json ) {
3394 echo '<script id="WiserReviewSchemaJson" type="application/ld+json">';
3395 echo $wiserrw_schema_json;
3396 echo '</script>';
3397 }
3398 }
3399
3400 function wiserrw_check_rest_api() {
3401 add_filter( 'https_ssl_verify', '__return_false' );
3402 $response = wp_remote_get( home_url( '/wp-json/' ) );
3403 remove_filter( 'https_ssl_verify', '__return_false' );
3404
3405 if ( is_wp_error( $response ) ) {
3406 echo 'WooCommerce REST API is not enabled. Enable it in settings and click Save again here..';
3407 return;
3408 }
3409
3410 $body = wp_remote_retrieve_body( $response );
3411 $data = json_decode( $body, true );
3412
3413 if ( !isset( $data['routes']['/wiserrw/v1/reviews'] ) ) {
3414 echo 'Please click Save again to apply the latest changes.';
3415 }
3416 }
3417
3418 function wiserrw_clear_wp_cache() {
3419 if( isset( $_GET['wiserrw_clear_cache'] ) && ( current_user_can( 'administrator' ) || current_user_can( 'editor' ) ) ) {
3420
3421 global $wp_fastest_cache;
3422
3423 // WP Rocket
3424 if ( function_exists( 'rocket_clean_domain' ) ) {
3425 rocket_clean_domain();
3426 }
3427
3428 if ( function_exists( 'wp_cache_flush' ) ) {
3429 wp_cache_flush();
3430 }
3431
3432 // WP Super Cache
3433 if ( function_exists( 'wp_cache_clear_cache' ) ) {
3434 wp_cache_clear_cache();
3435 }
3436
3437 // W3 Total Cache
3438 if ( function_exists( 'w3tc_flush_posts' ) ) {
3439 w3tc_flush_posts();
3440 }
3441
3442 // Cache Enabler
3443 if ( has_action( 'ce_clear_cache' ) ) {
3444 do_action( 'ce_clear_cache' );
3445 }
3446
3447 // Breeze
3448 if ( class_exists( 'Breeze_PurgeCache' ) ) {
3449 if ( method_exists( 'Breeze_PurgeCache', 'breeze_cache_flush' ) ) {
3450 Breeze_PurgeCache::breeze_cache_flush();
3451 }
3452 }
3453
3454 if ( class_exists( 'Swift_Performance_Cache' ) ) {
3455 if ( method_exists( 'Swift_Performance_Cache', 'clear_all_cache' ) ) {
3456 Swift_Performance_Cache::clear_all_cache();
3457 }
3458 }
3459
3460 // WP Fastest Cache
3461 if ( method_exists( 'WpFastestCache', 'deleteCache' ) && ! empty( $wp_fastest_cache ) ) {
3462 $wp_fastest_cache->deleteCache();
3463 }
3464
3465 // Autoptimize
3466 if ( class_exists( 'WpeCommon' ) ) {
3467 if ( method_exists( 'WpeCommon', 'purge_memcached' ) ) {
3468 WpeCommon::purge_memcached();
3469 }
3470 if ( method_exists( 'WpeCommon', 'clear_maxcdn_cache' ) ) {
3471 WpeCommon::clear_maxcdn_cache();
3472 }
3473 if ( method_exists( 'WpeCommon', 'purge_varnish_cache' ) ) {
3474 WpeCommon::purge_varnish_cache();
3475 }
3476 }
3477
3478 // SGOptimzer
3479 if ( function_exists('sg_cachepress_purge_cache') ) {
3480 sg_cachepress_purge_cache();
3481 }
3482 }
3483 }
3484 add_action( 'admin_init', 'wiserrw_clear_wp_cache' );
3485
3486 function wiserrw_send_product_update_on_save_post( $post_ID ) {
3487
3488 // WooCommerce must be available
3489 if ( ! class_exists( 'WooCommerce' ) ) return;
3490
3491 $product = wc_get_product( $post_ID );
3492 if ( ! $product ) return;
3493
3494 $wsid = get_option('wiserrw_wsid',true);
3495 $wiserrw_api_settings = get_option( 'wiserrw_api_settings', array() );
3496 $api_key = $wiserrw_api_settings['wiserrw_api_key'] ?? '';
3497 $data = array(
3498 'wsid' => $wsid,
3499 'pid' => $post_ID
3500 );
3501 // Make the request
3502 $response = wp_remote_post(
3503 WISERRW_RS_HOST . '/api/wp/updSchema',
3504 array(
3505 'method' => 'POST',
3506 'headers' => array(
3507 'Content-Type' => 'application/json',
3508 'x-wiserrw-api-key' => $api_key,
3509 ),
3510 'timeout' => 45,
3511 'redirection' => 5,
3512 'httpversion' => '1.0',
3513 'blocking' => true,
3514 'body' => wp_json_encode( $data ),
3515 'ver' => WISERRW_PLUGIN_VERSION
3516 )
3517 );
3518
3519 update_option( 'wiserrw__update_hook',wp_remote_retrieve_body($response) );
3520
3521 }
3522
3523 function wiserrw_show_product_rating_on_collection_woo_events() {
3524 $wiserrw_api_settings = (array) get_option( 'wiserrw_api_settings', [] );
3525
3526 if ( ! empty( $wiserrw_api_settings['wiserrw_product_card_enabled'] ) && $wiserrw_api_settings['wiserrw_product_card_enabled'] === '1' ) {
3527 $pid = wiserrw_get_current_product_id();
3528 echo wiserrw_rating_count( array( 'id' => $pid ) );
3529 wiserrw_restore_product_globals();
3530 }
3531 }
3532
3533 add_action( 'we_after_grid_content_html', 'wiserrw_show_product_rating_on_collection_woo_events', 5 );
3534
3535 // WiserReview Shortcode: [wiserreview_widget id="WidgetID value" type="carousel"]
3536 function wiserreview_widget_shortcode( $atts ) {
3537 $wsid = get_option('wiserrw_wsid',true);
3538 $atts = shortcode_atts( array(
3539 'id' => '',
3540 'type' => '',
3541 ), $atts, 'carousel_widget' );
3542
3543 if ( empty( $atts['id'] ) ) {
3544 return 'No widget ID provided';
3545 }
3546
3547 if ( empty( $atts['type'] ) ) {
3548 return 'No widget Type provided';
3549 }
3550
3551 $widget_id = sanitize_text_field( $atts['id'] );
3552 $type = sanitize_text_field( $atts['type'] );
3553 $option_key = 'wiserreview_widget_' . $widget_id;
3554
3555 // 1. If cache exists → serve instantly
3556 $cached_html = get_option( $option_key );
3557 if ( ! empty( $cached_html ) ) {
3558 return $cached_html;
3559 }
3560
3561 // 2. Render fallback immediately
3562 $fallback = wiserreview_shortcode_fallback( $widget_id, $type );
3563
3564 // 3. Try API fetch in same request (non-blocking for rendering)
3565 // The user sees fallback, but next time cache will be ready
3566 // $api_url = add_query_arg(
3567 // array(
3568 // 'widgetId' => $widget_id,
3569 // 'type' => $type,
3570 // 'ver' => WISERRW_PLUGIN_VERSION
3571 // ),
3572 // WISERRW_RS_HOST . '/api/getWdgtHTML'
3573
3574 // );
3575
3576 // $wiserrw_api_settings = get_option( 'wiserrw_api_settings', array() );
3577 // $api_key = $wiserrw_api_settings['wiserrw_api_key'] ?? '';
3578 // $response = wp_remote_get( $api_url, array(
3579 // 'timeout' => 20,
3580 // 'headers' => array(
3581 // 'x-wiserrw-api-key' => $api_key,
3582 // ),
3583 // ) );
3584
3585 // if ( ! is_wp_error( $response ) ) {
3586 // $json = json_decode( wp_remote_retrieve_body( $response ), true );
3587 // if ( isset( $json['html'] ) && ! empty( $json['html'] ) ) {
3588 // update_option( $option_key, $json['html'] );
3589 // wiserrw_update_html( $wsid, $widget_id );
3590 // }
3591 // }
3592
3593 // 2. Cache miss → schedule async fetch (one job per widget) and return fallback now.
3594 // The remote API call + wiserrw_update_html() run in the background via
3595 // Action Scheduler (bundled with WooCommerce) or wp-cron fallback so the
3596 // page render is not held up. See wiserrw_async_widget_fetch_handler() below.
3597
3598 $lock_key = 'wiserrw_widget_lock_' . $widget_id;
3599 if ( false === get_transient( $lock_key ) ) {
3600 // 60s lock prevents a thundering herd: only the first cache-miss
3601 // visitor schedules the fetch; concurrent visitors back off and
3602 // simply render the fallback while the job is in flight.
3603 set_transient( $lock_key, time(), 60 );
3604 $args = array( $widget_id, $type );
3605
3606 // Prefer Action Scheduler (bundled with WooCommerce 4.0+). It runs
3607 // jobs in a dedicated background queue, so visitor PHP workers
3608 // never execute the upstream fetch. WP-Cron fallback is only used
3609 // when AS is unavailable (rare — non-WC sites or stripped builds).
3610 if ( function_exists( 'as_enqueue_async_action' ) ) {
3611 as_enqueue_async_action( 'wiserrw_async_widget_fetch', $args, 'wiserreview' );
3612 } else {
3613 wp_schedule_single_event( time() + 1, 'wiserrw_async_widget_fetch', $args );
3614 }
3615 }
3616
3617 return $fallback;
3618 }
3619 add_action( 'wiserrw_async_widget_fetch', 'wiserrw_async_widget_fetch_handler', 10, 2 );
3620
3621 function wiserrw_async_widget_fetch_handler( $widget_id, $type ) {
3622 $option_key = 'wiserreview_widget_' . $widget_id;
3623 $lock_key = 'wiserrw_widget_lock_' . $widget_id;
3624 $wsid = get_option( 'wiserrw_wsid', true );
3625
3626 $api_url = add_query_arg(
3627 array(
3628 'widgetId' => $widget_id,
3629 'type' => $type,
3630 'ver' => WISERRW_PLUGIN_VERSION
3631 ),
3632 WISERRW_RS_HOST . '/api/getWdgtHTML'
3633
3634 );
3635
3636 $wiserrw_api_settings = get_option( 'wiserrw_api_settings', array() );
3637 $api_key = $wiserrw_api_settings['wiserrw_api_key'] ?? '';
3638 $response = wp_remote_get( $api_url, array(
3639 'timeout' => 20,
3640 'headers' => array(
3641 'x-wiserrw-api-key' => $api_key,
3642 ),
3643 ) );
3644
3645 if ( ! is_wp_error( $response ) ) {
3646 $json = json_decode( wp_remote_retrieve_body( $response ), true );
3647 if ( isset( $json['html'] ) && ! empty( $json['html'] ) ) {
3648 update_option( $option_key, $json['html'] );
3649 wiserrw_update_html( $wsid, $widget_id );
3650 }
3651 }
3652
3653 delete_transient( $lock_key );
3654 }
3655
3656 add_shortcode( 'wiserreview_widget', 'wiserreview_widget_shortcode' );
3657
3658 function wiserreview_shortcode_fallback( $widget_id, $type ) {
3659 $widget_id = esc_attr( $widget_id );
3660 $type = esc_attr( $type );
3661
3662 return '<script src="https://embed.wiserreview.com/embed/' . $widget_id . '/widget.js" defer></script>'
3663 . '<div data-type="' . $type . '" class="wiser_review_' . $type . '" data-id="' . $widget_id . '"></div>';
3664 }
3665