PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.9.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.9.16
1.10.19 1.10.18 1.10.17 1.10.16 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 All 163 releases
woocommerce-pos / includes / Admin / Consent.php

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

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