| 1 |
<?php |
| 2 |
/** |
| 3 |
* Tracking consent opt-in. |
| 4 |
* |
| 5 |
* Shows a pop-up modal when the plugin is activated or updated, and a |
| 6 |
* persistent callout on the Plugins screen and Dashboard until the user |
| 7 |
* makes a decision. Once the user has chosen allow/deny we stop asking. |
| 8 |
* |
| 9 |
* @author Paul Kilmurray <paul@kilbot.com> |
| 10 |
* |
| 11 |
* @see http://wcpos.com |
| 12 |
* @package WCPOS\WooCommercePOS |
| 13 |
*/ |
| 14 |
|
| 15 |
namespace WCPOS\WooCommercePOS\Admin; |
| 16 |
|
| 17 |
use WCPOS\WooCommercePOS\Services\Settings as SettingsService; |
| 18 |
use WCPOS\WooCommercePOS\Services\Lifecycle_Events; |
| 19 |
use WP_Error; |
| 20 |
use WP_REST_Request; |
| 21 |
use WP_REST_Response; |
| 22 |
use WP_REST_Server; |
| 23 |
use const WCPOS\WooCommercePOS\PLUGIN_FILE; |
| 24 |
use const WCPOS\WooCommercePOS\PLUGIN_NAME; |
| 25 |
use const WCPOS\WooCommercePOS\PLUGIN_URL; |
| 26 |
use const WCPOS\WooCommercePOS\SHORT_NAME; |
| 27 |
use const WCPOS\WooCommercePOS\TRANSLATION_VERSION; |
| 28 |
use const WCPOS\WooCommercePOS\VERSION; |
| 29 |
|
| 30 |
/** |
| 31 |
* Class Consent. |
| 32 |
* |
| 33 |
* Registered from both the plugin bootstrap (for the lifecycle hooks) and |
| 34 |
* from Admin::init() (so the frontend asset is enqueued on wp-admin page |
| 35 |
* loads). |
| 36 |
*/ |
| 37 |
class Consent { |
| 38 |
/** |
| 39 |
* Transient name used to auto-open the consent modal on the next |
| 40 |
* admin page load after activation or update. |
| 41 |
*/ |
| 42 |
public const MODAL_TRANSIENT = 'wcpos_show_consent_modal'; |
| 43 |
|
| 44 |
/** |
| 45 |
* Transient lifetime in seconds (10 minutes). |
| 46 |
*/ |
| 47 |
public const MODAL_TRANSIENT_TTL = 600; |
| 48 |
|
| 49 |
/** |
| 50 |
* User meta key storing the unix timestamp until which the callout |
| 51 |
* is hidden for a given user after they dismiss it with the X button. |
| 52 |
* |
| 53 |
* Dismissing does NOT record a consent decision — it only defers the |
| 54 |
* callout; once the timestamp expires (or the plugin is reactivated / |
| 55 |
* updated), the callout surfaces again. |
| 56 |
*/ |
| 57 |
public const CALLOUT_HIDE_META = '_wcpos_consent_callout_hidden_until'; |
| 58 |
|
| 59 |
/** |
| 60 |
* "Hide for now" lifetime in seconds (7 days). |
| 61 |
*/ |
| 62 |
public const CALLOUT_HIDE_TTL = 7 * DAY_IN_SECONDS; |
| 63 |
|
| 64 |
/** |
| 65 |
* Hook suffixes where the inline callout + modal mount point are |
| 66 |
* allowed. The Plugins screen is the primary target (users land |
| 67 |
* here after activation) and the Dashboard is the fallback. |
| 68 |
* |
| 69 |
* @var string[] |
| 70 |
*/ |
| 71 |
private const ALLOWED_HOOK_SUFFIXES = array( 'plugins.php', 'index.php' ); |
| 72 |
|
| 73 |
/** |
| 74 |
* Register lifecycle + REST hooks. |
| 75 |
*/ |
| 76 |
public function __construct() { |
| 77 |
// Lifecycle — set the "show the modal" flag. |
| 78 |
add_action( 'activated_plugin', array( $this, 'on_plugin_activated' ), 10, 1 ); |
| 79 |
add_action( 'upgrader_process_complete', array( $this, 'on_upgrader_process_complete' ), 10, 2 ); |
| 80 |
|
| 81 |
// Render — enqueue the React bundle on qualifying admin screens. |
| 82 |
add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue' ) ); |
| 83 |
add_action( 'admin_notices', array( $this, 'maybe_render_mount_point' ) ); |
| 84 |
|
| 85 |
// REST — persistence endpoint for the user's choice. |
| 86 |
add_action( 'rest_api_init', array( $this, 'register_routes' ) ); |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Flag the consent modal for display when our plugin is activated. |
| 91 |
* |
| 92 |
* Fires after activation via the 'activated_plugin' action. Only sets |
| 93 |
* the transient for our plugin file, and only when the user has not |
| 94 |
* already made a decision. |
| 95 |
* |
| 96 |
* @param string $plugin The activated plugin file, relative to WP_PLUGIN_DIR. |
| 97 |
*/ |
| 98 |
public function on_plugin_activated( $plugin ): void { |
| 99 |
if ( ! is_string( $plugin ) ) { |
| 100 |
return; |
| 101 |
} |
| 102 |
|
| 103 |
if ( plugin_basename( PLUGIN_FILE ) !== $plugin ) { |
| 104 |
return; |
| 105 |
} |
| 106 |
|
| 107 |
$this->maybe_set_modal_transient(); |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Flag the consent modal after our plugin is updated via the updater. |
| 112 |
* |
| 113 |
* @param mixed $upgrader Instance of the upgrader performing the update. |
| 114 |
* @param array $data Array of bulk item update data. |
| 115 |
*/ |
| 116 |
public function on_upgrader_process_complete( $upgrader, $data ): void { |
| 117 |
if ( ! \is_array( $data ) ) { |
| 118 |
return; |
| 119 |
} |
| 120 |
|
| 121 |
$type = isset( $data['type'] ) ? $data['type'] : ''; |
| 122 |
$action = isset( $data['action'] ) ? $data['action'] : ''; |
| 123 |
if ( 'plugin' !== $type || 'update' !== $action ) { |
| 124 |
return; |
| 125 |
} |
| 126 |
|
| 127 |
// Normalize both upgrader payload shapes: bulk updates pass a |
| 128 |
// 'plugins' array while single-plugin updates pass a scalar |
| 129 |
// 'plugin' key. |
| 130 |
$plugins = array(); |
| 131 |
if ( isset( $data['plugin'] ) && \is_string( $data['plugin'] ) ) { |
| 132 |
$plugins[] = $data['plugin']; |
| 133 |
} |
| 134 |
if ( isset( $data['plugins'] ) && \is_array( $data['plugins'] ) ) { |
| 135 |
$plugins = array_merge( $plugins, $data['plugins'] ); |
| 136 |
} |
| 137 |
|
| 138 |
$target = plugin_basename( PLUGIN_FILE ); |
| 139 |
if ( ! \in_array( $target, $plugins, true ) ) { |
| 140 |
return; |
| 141 |
} |
| 142 |
|
| 143 |
$this->maybe_set_modal_transient(); |
| 144 |
} |
| 145 |
|
| 146 |
/** |
| 147 |
* Set the modal display transient only if the user hasn't yet decided. |
| 148 |
* |
| 149 |
* Keeps the transient from piling up for users who have already |
| 150 |
* opted in or out. |
| 151 |
*/ |
| 152 |
private function maybe_set_modal_transient(): void { |
| 153 |
if ( 'undecided' !== SettingsService::instance()->tracking_consent() ) { |
| 154 |
return; |
| 155 |
} |
| 156 |
|
| 157 |
set_transient( self::MODAL_TRANSIENT, 1, self::MODAL_TRANSIENT_TTL ); |
| 158 |
|
| 159 |
// Activation/update re-surfaces the callout — clear any prior |
| 160 |
// "hide for now" state for the current user so the prompt is |
| 161 |
// unmissable on the next admin page load. |
| 162 |
$user_id = get_current_user_id(); |
| 163 |
if ( $user_id ) { |
| 164 |
delete_user_meta( $user_id, self::CALLOUT_HIDE_META ); |
| 165 |
} |
| 166 |
} |
| 167 |
|
| 168 |
/** |
| 169 |
* Whether the callout is currently hidden for the given user via a |
| 170 |
* "hide for now" dismissal. Expired entries are cleaned up opportunistically. |
| 171 |
* |
| 172 |
* @param int $user_id WP user ID. |
| 173 |
*/ |
| 174 |
private function is_callout_hidden_for_user( $user_id ): bool { |
| 175 |
if ( ! $user_id ) { |
| 176 |
return false; |
| 177 |
} |
| 178 |
|
| 179 |
$hidden_until = (int) get_user_meta( $user_id, self::CALLOUT_HIDE_META, true ); |
| 180 |
if ( ! $hidden_until ) { |
| 181 |
return false; |
| 182 |
} |
| 183 |
|
| 184 |
if ( $hidden_until <= time() ) { |
| 185 |
delete_user_meta( $user_id, self::CALLOUT_HIDE_META ); |
| 186 |
|
| 187 |
return false; |
| 188 |
} |
| 189 |
|
| 190 |
return true; |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Decide whether to enqueue the consent bundle on the current screen. |
| 195 |
* |
| 196 |
* Runs on every admin page but only does work on the two allowed |
| 197 |
* screens and only while the user has not made a decision. |
| 198 |
* |
| 199 |
* @param string $hook_suffix WordPress admin page hook suffix. |
| 200 |
*/ |
| 201 |
public function maybe_enqueue( $hook_suffix ): void { |
| 202 |
if ( ! $this->should_render( $hook_suffix ) ) { |
| 203 |
return; |
| 204 |
} |
| 205 |
|
| 206 |
$is_development = isset( $_ENV['DEVELOPMENT'] ) |
| 207 |
&& wp_validate_boolean( sanitize_text_field( wp_unslash( $_ENV['DEVELOPMENT'] ) ) ); |
| 208 |
$dir = $is_development ? 'build' : 'assets'; |
| 209 |
|
| 210 |
wp_enqueue_style( |
| 211 |
PLUGIN_NAME . '-consent-styles', |
| 212 |
PLUGIN_URL . $dir . '/css/consent.css', |
| 213 |
array(), |
| 214 |
VERSION |
| 215 |
); |
| 216 |
|
| 217 |
wp_enqueue_script( |
| 218 |
PLUGIN_NAME . '-consent', |
| 219 |
PLUGIN_URL . $dir . '/js/consent.js', |
| 220 |
array( 'react', 'react-dom', 'wp-url' ), |
| 221 |
VERSION, |
| 222 |
true |
| 223 |
); |
| 224 |
|
| 225 |
wp_add_inline_script( |
| 226 |
PLUGIN_NAME . '-consent', |
| 227 |
$this->inline_script( $hook_suffix ), |
| 228 |
'before' |
| 229 |
); |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* Print the mount point element. Paired with maybe_enqueue(). |
| 234 |
* |
| 235 |
* @param string|null $hook_suffix Optional hook suffix override (used by tests). |
| 236 |
*/ |
| 237 |
public function maybe_render_mount_point( $hook_suffix = null ): void { |
| 238 |
// WP's do_action( 'admin_notices' ) passes '' (empty string) to |
| 239 |
// single-arg callbacks, bypassing the null default. Treat empty |
| 240 |
// string the same as null so the lookup below still fires. |
| 241 |
if ( null === $hook_suffix || '' === $hook_suffix ) { |
| 242 |
// WP screen ids differ from hook_suffixes (e.g. 'dashboard' vs |
| 243 |
// 'index.php'). Prefer $GLOBALS['hook_suffix'] which is set right |
| 244 |
// before admin_notices fires, and fall back to current_screen — |
| 245 |
// normalizing the screen id to the hook_suffix shape so the |
| 246 |
// allowlist in should_render() can match. |
| 247 |
$hook_suffix = ''; |
| 248 |
if ( isset( $GLOBALS['hook_suffix'] ) && \is_string( $GLOBALS['hook_suffix'] ) ) { |
| 249 |
$hook_suffix = $GLOBALS['hook_suffix']; |
| 250 |
} else { |
| 251 |
$screen = get_current_screen(); |
| 252 |
if ( $screen ) { |
| 253 |
$screen_to_hook = array( |
| 254 |
'dashboard' => 'index.php', |
| 255 |
'plugins' => 'plugins.php', |
| 256 |
); |
| 257 |
$hook_suffix = isset( $screen_to_hook[ $screen->id ] ) ? $screen_to_hook[ $screen->id ] : ''; |
| 258 |
} |
| 259 |
} |
| 260 |
} |
| 261 |
|
| 262 |
if ( ! $this->should_render( $hook_suffix ) ) { |
| 263 |
return; |
| 264 |
} |
| 265 |
|
| 266 |
// WP core's common.js hoists any element matching `.notice` |
| 267 |
// beneath the page H1 and gives it the standard admin-notice |
| 268 |
// width/margins. The `is-dismissible` class reserves right-hand |
| 269 |
// padding for the dismiss button that the React bundle renders. |
| 270 |
echo '<div id="wcpos-consent-root" class="notice notice-info is-dismissible"></div>'; |
| 271 |
} |
| 272 |
|
| 273 |
/** |
| 274 |
* Register the consent REST endpoint. |
| 275 |
*/ |
| 276 |
public function register_routes(): void { |
| 277 |
// Only expose the consent REST routes on WCPOS-flagged requests, matching the |
| 278 |
// rest of /wcpos/v1/ (see Init::init_rest_api). Limits the always-on surface. |
| 279 |
if ( ! woocommerce_pos_request() ) { |
| 280 |
return; |
| 281 |
} |
| 282 |
|
| 283 |
register_rest_route( |
| 284 |
SHORT_NAME . '/v1', |
| 285 |
'/consent', |
| 286 |
array( |
| 287 |
'methods' => WP_REST_Server::CREATABLE, |
| 288 |
'callback' => array( $this, 'save_consent' ), |
| 289 |
'permission_callback' => array( $this, 'permission_check' ), |
| 290 |
'args' => array( |
| 291 |
'consent' => array( |
| 292 |
'type' => 'string', |
| 293 |
'enum' => array( 'allowed', 'denied' ), |
| 294 |
'required' => true, |
| 295 |
), |
| 296 |
), |
| 297 |
) |
| 298 |
); |
| 299 |
|
| 300 |
register_rest_route( |
| 301 |
SHORT_NAME . '/v1', |
| 302 |
'/consent/dismiss', |
| 303 |
array( |
| 304 |
'methods' => WP_REST_Server::CREATABLE, |
| 305 |
'callback' => array( $this, 'dismiss_callout' ), |
| 306 |
'permission_callback' => array( $this, 'permission_check' ), |
| 307 |
) |
| 308 |
); |
| 309 |
} |
| 310 |
|
| 311 |
/** |
| 312 |
* REST permission callback. Must be able to manage WCPOS. |
| 313 |
* |
| 314 |
* @return bool|WP_Error |
| 315 |
*/ |
| 316 |
public function permission_check() { |
| 317 |
if ( ! current_user_can( 'manage_woocommerce_pos' ) ) { |
| 318 |
return new WP_Error( 'wcpos_consent_forbidden', __( 'You do not have permission to update WCPOS settings.', 'woocommerce-pos' ), array( 'status' => 403 ) ); |
| 319 |
} |
| 320 |
|
| 321 |
return true; |
| 322 |
} |
| 323 |
|
| 324 |
/** |
| 325 |
* Persist the user's consent choice. |
| 326 |
* |
| 327 |
* @param WP_REST_Request $request REST request instance. |
| 328 |
* |
| 329 |
* @return WP_REST_Response|WP_Error |
| 330 |
*/ |
| 331 |
public function save_consent( WP_REST_Request $request ) { |
| 332 |
$choice = $request->get_param( 'consent' ); |
| 333 |
if ( ! \in_array( $choice, array( 'allowed', 'denied' ), true ) ) { |
| 334 |
return new WP_Error( 'wcpos_consent_invalid', /* translators: Short WCPOS UI label; keep concise. */ __( 'Invalid consent value.', 'woocommerce-pos' ), array( 'status' => 400 ) ); |
| 335 |
} |
| 336 |
|
| 337 |
$settings = woocommerce_pos_get_settings( 'general' ); |
| 338 |
if ( ! \is_array( $settings ) ) { |
| 339 |
return new WP_Error( 'wcpos_consent_load_failed', __( 'Unable to load general settings.', 'woocommerce-pos' ), array( 'status' => 500 ) ); |
| 340 |
} |
| 341 |
|
| 342 |
$settings['tracking_consent'] = $choice; |
| 343 |
$result = SettingsService::instance()->save_settings( 'general', $settings ); |
| 344 |
if ( is_wp_error( $result ) ) { |
| 345 |
return $result; |
| 346 |
} |
| 347 |
|
| 348 |
// Decision recorded — clear any pending auto-open flag and any |
| 349 |
// lingering "hide for now" user meta so the state is coherent. |
| 350 |
delete_transient( self::MODAL_TRANSIENT ); |
| 351 |
$user_id = get_current_user_id(); |
| 352 |
if ( $user_id ) { |
| 353 |
delete_user_meta( $user_id, self::CALLOUT_HIDE_META ); |
| 354 |
} |
| 355 |
|
| 356 |
// Only a yes is reported. A no is answered by sending nothing at all. |
| 357 |
// No surface is attached here: the server cannot tell which prompt the |
| 358 |
// user answered in, and the paired consent_notice_viewed already |
| 359 |
// carries the surface that was shown. |
| 360 |
if ( 'allowed' === $choice ) { |
| 361 |
( new Lifecycle_Events() )->report_consent_granted(); |
| 362 |
} else { |
| 363 |
// Discard the queued prompt view now rather than leaving it in the |
| 364 |
// options table until some later admin_init notices the refusal. |
| 365 |
// A no should take effect in the request that records it. |
| 366 |
( new Lifecycle_Events() )->discard_pending(); |
| 367 |
} |
| 368 |
|
| 369 |
return new WP_REST_Response( array( 'consent' => $choice ), 200 ); |
| 370 |
} |
| 371 |
|
| 372 |
/** |
| 373 |
* Hide the inline callout for the current user for self::CALLOUT_HIDE_TTL. |
| 374 |
* |
| 375 |
* Does NOT record a consent decision — tracking_consent stays |
| 376 |
* 'undecided' and the callout will re-appear after the hide window |
| 377 |
* expires or on the next plugin activation/update. |
| 378 |
* |
| 379 |
* @return WP_REST_Response|WP_Error |
| 380 |
*/ |
| 381 |
public function dismiss_callout() { |
| 382 |
$user_id = get_current_user_id(); |
| 383 |
if ( ! $user_id ) { |
| 384 |
return new WP_Error( 'wcpos_consent_no_user', /* translators: Short WCPOS UI label; keep concise. */ __( 'No current user.', 'woocommerce-pos' ), array( 'status' => 401 ) ); |
| 385 |
} |
| 386 |
|
| 387 |
$hidden_until = time() + self::CALLOUT_HIDE_TTL; |
| 388 |
update_user_meta( $user_id, self::CALLOUT_HIDE_META, $hidden_until ); |
| 389 |
|
| 390 |
return new WP_REST_Response( array( 'hiddenUntil' => $hidden_until ), 200 ); |
| 391 |
} |
| 392 |
|
| 393 |
/** |
| 394 |
* Determine whether the consent UI should render on the given screen. |
| 395 |
* |
| 396 |
* @param string $hook_suffix Admin page hook suffix. |
| 397 |
*/ |
| 398 |
private function should_render( $hook_suffix ): bool { |
| 399 |
if ( ! \is_string( $hook_suffix ) || '' === $hook_suffix ) { |
| 400 |
return false; |
| 401 |
} |
| 402 |
|
| 403 |
if ( ! \in_array( $hook_suffix, self::ALLOWED_HOOK_SUFFIXES, true ) ) { |
| 404 |
return false; |
| 405 |
} |
| 406 |
|
| 407 |
if ( ! current_user_can( 'manage_woocommerce_pos' ) ) { |
| 408 |
return false; |
| 409 |
} |
| 410 |
|
| 411 |
if ( 'undecided' !== SettingsService::instance()->tracking_consent() ) { |
| 412 |
return false; |
| 413 |
} |
| 414 |
|
| 415 |
if ( $this->is_callout_hidden_for_user( get_current_user_id() ) ) { |
| 416 |
return false; |
| 417 |
} |
| 418 |
|
| 419 |
return true; |
| 420 |
} |
| 421 |
|
| 422 |
/** |
| 423 |
* Build the inline configuration object read by the React bundle. |
| 424 |
* |
| 425 |
* @param string $hook_suffix Admin page hook suffix for the current request. |
| 426 |
*/ |
| 427 |
private function inline_script( $hook_suffix ): string { |
| 428 |
// Modal is only auto-opened on the Plugins screen (where users |
| 429 |
// land after activation) and only when the transient is set. |
| 430 |
// We clear the transient immediately so it only fires once. |
| 431 |
$show_modal = false; |
| 432 |
if ( 'plugins.php' === $hook_suffix && get_transient( self::MODAL_TRANSIENT ) ) { |
| 433 |
$show_modal = true; |
| 434 |
delete_transient( self::MODAL_TRANSIENT ); |
| 435 |
} |
| 436 |
|
| 437 |
// Record the sighting HERE, not at render time: this is the only place |
| 438 |
// that knows which surface the user actually gets, and it consumes the |
| 439 |
// transient that decides it. Reading the transient later reports the |
| 440 |
// opposite surface every time. |
| 441 |
// |
| 442 |
// Queued, never sent — maybe_enqueue() only reaches this while the |
| 443 |
// answer is undecided, so nothing may leave the site yet. It arrives at |
| 444 |
// PostHog only if this user goes on to allow tracking. |
| 445 |
( new Lifecycle_Events() )->record_consent_prompt_viewed( $show_modal ? 'modal' : 'callout' ); |
| 446 |
|
| 447 |
// Append the WCPOS request flag so the bundle's REST calls register the |
| 448 |
// now-gated consent routes (see register_routes / Init::init_rest_api). |
| 449 |
$config = array( |
| 450 |
'restUrl' => esc_url_raw( add_query_arg( 'wcpos', '1', rest_url( SHORT_NAME . '/v1/consent' ) ) ), |
| 451 |
'dismissUrl' => esc_url_raw( add_query_arg( 'wcpos', '1', rest_url( SHORT_NAME . '/v1/consent/dismiss' ) ) ), |
| 452 |
'nonce' => wp_create_nonce( 'wp_rest' ), |
| 453 |
'showModal' => $show_modal, |
| 454 |
'showCallout' => true, |
| 455 |
/** |
| 456 |
* Filters the consent-prompt copy overrides. |
| 457 |
* |
| 458 |
* Keys (all optional; the consent UI keeps its built-in string for |
| 459 |
* any missing key): 'title', 'body', 'fields_intro', 'allow_label', |
| 460 |
* 'deny_label', 'privacy_note'. Used by the landing-experiments |
| 461 |
* consent-ask test (exp-202607) to vary the prompt without a |
| 462 |
* plugin release. Every claim in override copy must be literally |
| 463 |
* true about what is read and where it goes. |
| 464 |
* |
| 465 |
* @since x.x.x (replace with the next release version at release time) |
| 466 |
* |
| 467 |
* @param array $copy Copy overrides, default empty. |
| 468 |
*/ |
| 469 |
'copy' => (object) apply_filters( 'woocommerce_pos_consent_copy', array() ), |
| 470 |
); |
| 471 |
|
| 472 |
return sprintf( |
| 473 |
'var wcpos = wcpos || {}; wcpos.consent = %s; wcpos.translationVersion = %s;', |
| 474 |
wp_json_encode( $config ), |
| 475 |
wp_json_encode( TRANSLATION_VERSION ) |
| 476 |
); |
| 477 |
} |
| 478 |
} |
| 479 |
|