PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 All 159 releases
woocommerce-pos / includes / Admin / Consent.php

Consent.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/Admin/Consent.php

479 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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