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 / Menu.php

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

673 lines 23.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WP Admin Menu Class.
4 *
5 * @author Paul Kilmurray <paul@kilbot.com>
6 *
7 * @see http://wcpos.com
8 * @package WCPOS\WooCommercePOS
9 */
10
11 namespace WCPOS\WooCommercePOS\Admin;
12
13 use WCPOS\WooCommercePOS\Logger;
14 use WCPOS\WooCommercePOS\Services\Analytics;
15 use WCPOS\WooCommercePOS\Services\Analytics_Profile;
16 use WCPOS\WooCommercePOS\Services\Landing_Profile;
17 use WCPOS\WooCommercePOS\Services\Lifecycle_Events;
18 use WCPOS\WooCommercePOS\Services\Settings;
19 use const WCPOS\WooCommercePOS\PLUGIN_NAME;
20 use const WCPOS\WooCommercePOS\PLUGIN_URL;
21 use const WCPOS\WooCommercePOS\TRANSLATION_VERSION;
22 use const WCPOS\WooCommercePOS\VERSION as PLUGIN_VERSION;
23
24 /**
25 * Menu class.
26 */
27 class Menu {
28 /**
29 * Unique top level menu identifier.
30 *
31 * @var string
32 */
33 public $toplevel_screen_id;
34
35 /**
36 * Unique top level menu identifier.
37 *
38 * @var string
39 */
40 public $settings_screen_id;
41
42 /**
43 * Gallery submenu page hook suffix.
44 *
45 * @var string
46 */
47 public $gallery_screen_id;
48
49 /**
50 * View POS submenu page hook suffix.
51 *
52 * @var string
53 */
54 public $view_pos_screen_id;
55
56 /**
57 * Constructor.
58 */
59 public function __construct() {
60 if ( current_user_can( 'manage_woocommerce_pos' ) ) {
61 $this->register_pos_admin();
62 add_filter( 'custom_menu_order', '__return_true' );
63 add_filter( 'menu_order', array( $this, 'menu_order' ), 9, 1 );
64 add_filter( 'parent_file', array( $this, 'highlight_templates_menu' ) );
65 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_landing_scripts_and_styles' ) );
66 add_action( 'admin_footer', array( $this, 'print_upgrade_click_tracking_script' ) );
67 add_action( 'admin_init', array( $this, 'redirect_template_list_page' ) );
68 }
69
70 // add_filter( 'woocommerce_analytics_report_menu_items', array( $this, 'analytics_menu_items' ) );.
71 }
72
73 /**
74 * Filters the order of administration menu items.
75 *
76 * A truthy value must first be passed to the {@see 'custom_menu_order'} filter
77 * for this filter to work. Use the following to enable custom menu ordering:
78 *
79 * add_filter( 'custom_menu_order', '__return_true' );
80 *
81 * @param array $menu_order An ordered array of menu items.
82 *
83 * @return array
84 */
85 public function menu_order( array $menu_order ): array {
86 $woo = array_search( 'woocommerce', $menu_order, true );
87 $pos = array_search( PLUGIN_NAME, $menu_order, true );
88
89 if ( false !== $woo && false !== $pos ) {
90 // rearrange menu.
91 unset( $menu_order[ $pos ] );
92 array_splice( $menu_order, ++$woo, 0, PLUGIN_NAME );
93
94 // rearrange submenu.
95 global $submenu;
96 $pos_submenu = &$submenu[ PLUGIN_NAME ];
97 $pos_submenu[500] = $pos_submenu[1];
98 unset( $pos_submenu[1] );
99 }
100
101 return $menu_order;
102 }
103
104 /**
105 * Render the upgrade page.
106 */
107 public function display_upgrade_page(): void {
108 include_once 'views/upgrade.php';
109 }
110
111 /**
112 * Add POS submenu to WooCommerce Analytics menu.
113 *
114 * @param array $report_pages The analytics report pages.
115 */
116 public function analytics_menu_items( array $report_pages ): array {
117 // Find the position of the 'Orders' item.
118 $position = array_search( 'Orders', array_column( $report_pages, 'title' ), true );
119
120 // Use array_splice to add the new item.
121 array_splice(
122 $report_pages,
123 $position + 1,
124 0,
125 array(
126 array(
127 'id' => 'woocommerce-analytics-pos',
128 'title' => /* translators: WordPress admin menu label for WCPOS. */ __( 'POS', 'woocommerce-pos' ),
129 'parent' => 'woocommerce-analytics',
130 'path' => '/analytics/pos',
131 'nav_args' => array(
132 'order' => 45,
133 'parent' => 'woocommerce-analytics',
134 ),
135 ),
136 )
137 );
138
139 return $report_pages;
140 }
141
142 /**
143 * Add POS to Admin sidebar.
144 */
145 private function register_pos_admin(): void {
146 $this->toplevel_screen_id = add_menu_page(
147 /* translators: WordPress admin menu label for WCPOS. */
148 __( 'POS', 'woocommerce-pos' ),
149 /* translators: WordPress admin menu label for WCPOS. */
150 __( 'POS', 'woocommerce-pos' ),
151 'manage_woocommerce_pos',
152 PLUGIN_NAME,
153 array( $this, 'display_upgrade_page' ),
154 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjYwIDEyNjAiPgo8cGF0aCBmaWxsPSIjYTdhYWFkIiBkPSJNMTE3MCwwaC05MEg5MDBINzIwSDU0MEgzNjBIMTgwSDkwQzMwLDAsMCwzMCwwLDkwdjE4MGMwLDQ5LjcsNDAuMyw5MCw5MCw5MHM5MC00MC4zLDkwLTkwVjkwaDE4MHYxODAKCWMwLDQ5LjcsNDAuMyw5MCw5MCw5MHM5MC00MC4zLDkwLTkwVjkwaDE4MHYxODBjMCw0OS43LDQwLjMsOTAsOTAsOTBzOTAtNDAuMyw5MC05MFY5MGgxODB2MTgwYzAsNDkuNyw0MC4zLDkwLDkwLDkwczkwLTQwLjMsOTAtOTAKCVY5MEMxMjYwLDMwLDEyMzAsMCwxMTcwLDB6Ii8+CjxwYXRoIGZpbGw9IiNhN2FhYWQiIGQ9Ik0xMDgwLDM2MGMtNDUsNDUtMTM1LDQ1LTE4MCwwYy00NSw0NS0xMzUsNDUtMTgwLDBjLTQ1LDQ1LTEzNSw0NS0xODAsMGMtNDUsNDUtMTM1LDQ1LTE4MCwwYy00NSw0NS0xMzUsNDUtMTgwLDAKCWMtNDUsNDUtMTM1LDQ1LTE4MCwwdjkwMGwzNjAtMjcwaDgxMGM2MCwwLDkwLTMwLDkwLTkwVjM2MEMxMjE1LDQwNSwxMTI1LDQwNSwxMDgwLDM2MHogTTI2MC41LDgyOGMtMzUuNSwwLTY4LjUtMTEuMy05NS41LTMwLjQKCXYxMjUuOWMwLDE5LjMtMTUuNywzNS0zNSwzNXMtMzUtMTUuNy0zNS0zNVY1MzJjMC0xOS4zLDE1LjctMzUsMzUtMzVjMTcuOCwwLDMyLjYsMTMuMywzNC43LDMwLjZjMjcuMS0xOS4zLDYwLjEtMzAuNiw5NS44LTMwLjYKCWM5MS4zLDAsMTY1LjUsNzQuMiwxNjUuNSwxNjUuNVMzNTEuOCw4MjgsMjYwLjUsODI4eiBNNjMwLDgyOGMtOTEuNSwwLTE2Ni03NC41LTE2Ni0xNjZjMC05MS41LDc0LjUtMTY2LDE2Ni0xNjYKCWM5MS41LDAsMTY2LDc0LjUsMTY2LDE2NkM3OTYsNzUzLjUsNzIxLjUsODI4LDYzMCw4Mjh6IE05MTguMyw2MTMuOWMxMS41LDUuOCwzNS4xLDEyLjYsODIuMiwxMi42YzQ5LjUsMCw4Ni42LDYuNSwxMTMuNSwyMAoJYzMzLjUsMTYuOCw1Miw0NS4zLDUyLDgwLjJzLTE4LjUsNjMuNS01Miw4MC4yYy0yNi45LDEzLjUtNjQuMSwyMC0xMTMuNSwyMEM5NDYsODI3LDg5Niw4MTMuNiw4NTIsNzg3LjJjLTE2LjYtOS45LTIyLTMxLjQtMTItNDgKCWM5LjktMTYuNiwzMS40LTIyLDQ4LTEyYzMzLDE5LjgsNzAuOCwyOS44LDExMi41LDI5LjhjNDcuMSwwLDcwLjctNi45LDgyLjItMTIuNmM3LjMtMy42LDEzLjMtNy41LDEzLjMtMTcuNnMtNi0xNC0xMy4zLTE3LjYKCWMtMTEuNS01LjgtMzUuMS0xMi42LTgyLjItMTIuNmMtNDkuNSwwLTg2LjYtNi41LTExMy41LTIwYy0zMy41LTE2LjgtNTItNDUuMy01Mi04MC4yYzAtMzUsMTguNS02My41LDUyLTgwLjIKCWMyNi45LTEzLjUsNjQuMS0yMCwxMTMuNS0yMGM1NC41LDAsMTA0LjUsMTMuNCwxNDguNSwzOS44YzE2LjYsOS45LDIyLDMxLjQsMTIsNDhjLTkuOSwxNi42LTMxLjQsMjEuOS00OCwxMgoJYy0zMy0xOS44LTcwLjgtMjkuOC0xMTIuNS0yOS44Yy00Ny4xLDAtNzAuNyw2LjktODIuMiwxMi42Yy03LjMsMy42LTEzLjMsNy41LTEzLjMsMTcuNlM5MTEsNjEwLjMsOTE4LjMsNjEzLjl6Ii8+CjxjaXJjbGUgZmlsbD0iI2E3YWFhZCIgY3g9IjYzMCIgY3k9IjY2MiIgcj0iOTYiLz4KPGNpcmNsZSBmaWxsPSIjYTdhYWFkIiBjeD0iMjYwLjUiIGN5PSI2NjIuNSIgcj0iOTUuNSIvPgo8L3N2Zz4K'
155 );
156
157 $this->view_pos_screen_id = add_submenu_page(
158 PLUGIN_NAME,
159 /* translators: WordPress admin menu label for WCPOS. */
160 __( 'View POS', 'woocommerce-pos' ),
161 /* translators: WordPress admin menu label for WCPOS. */
162 __( 'View POS', 'woocommerce-pos' ),
163 'manage_woocommerce_pos',
164 PLUGIN_NAME . '-view-pos',
165 );
166
167 $this->settings_screen_id = add_submenu_page(
168 PLUGIN_NAME,
169 // translators: WordPress admin submenu label that opens WCPOS settings.
170 __( 'Settings', 'woocommerce-pos' ),
171 // translators: WordPress admin submenu label that opens WCPOS settings.
172 __( 'Settings', 'woocommerce-pos' ),
173 'manage_woocommerce_pos',
174 PLUGIN_NAME . '-settings',
175 array( '\WCPOS\WooCommercePOS\Admin\Settings', 'display_settings_page' )
176 );
177
178 // Template Gallery SPA page.
179 $this->gallery_screen_id = add_submenu_page(
180 PLUGIN_NAME,
181 /* translators: WordPress admin menu label for WCPOS. */
182 __( 'Templates', 'woocommerce-pos' ),
183 /* translators: WordPress admin menu label for WCPOS. */
184 __( 'Templates', 'woocommerce-pos' ),
185 'manage_woocommerce_pos',
186 'wcpos-templates',
187 array( $this, 'render_gallery_page' )
188 );
189 add_action( 'load-' . $this->gallery_screen_id, array( $this, 'enqueue_gallery_assets' ) );
190
191 // adjust submenu.
192 global $submenu;
193 $pos_submenu = &$submenu[ PLUGIN_NAME ];
194 $pos_submenu[0][0] = /* translators: WordPress admin menu label for WCPOS. */ __( 'Upgrade to Pro', 'woocommerce-pos' );
195 $pos_submenu[0][2] = self::get_upgrade_tracking_url(
196 'menu_submenu',
197 admin_url( 'admin.php?page=' . PLUGIN_NAME )
198 );
199 $pos_submenu[1][2] = woocommerce_pos_url();
200
201 // The "Upgrade to Pro" submenu link is persistent navigation rendered
202 // on every wp-admin page, so we deliberately do NOT emit an
203 // `upgrade_cta_viewed` impression here — doing so fired the event on
204 // every admin request and buried the rest of the funnel. Clicks are
205 // still tracked via the tracking URL set on $pos_submenu[0][2] above.
206
207 /*
208 * Fires after POS admin menus are registered.
209 *
210 * The array arguments, `$this->toplevel_screen_id` and
211 * `$this->settings_screen_id`, refers to the top-level POS menu ID and
212 * settings submenu ID respectively.
213 *
214 * @since 1.0.0
215 *
216 * @param array $menus {
217 * An array of admin menu IDs.
218 *
219 * @type string $toplevel The top-level POS menu ID.
220 * @type string $settings The settings submenu ID.
221 * }
222 */
223 do_action(
224 'woocommerce_pos_register_pos_admin',
225 array(
226 'toplevel' => $this->toplevel_screen_id,
227 'settings' => $this->settings_screen_id,
228 )
229 );
230 }
231
232 /**
233 * Enqueue landing page scripts and styles.
234 *
235 * @param string $hook_suffix The current admin page hook suffix.
236 */
237 public function enqueue_landing_scripts_and_styles( $hook_suffix ): void {
238 if ( $hook_suffix === $this->toplevel_screen_id ) {
239 $analytics = Analytics::instance();
240
241 $analytics->capture_once(
242 'upgrade_cta_viewed',
243 array(
244 'placement' => 'admin_landing_banner',
245 ),
246 'admin_landing_banner'
247 );
248
249 // Bind this install to its `site` group and refresh the profile that
250 // hangs off it. Sending the properties matters: a bare group() call
251 // creates the group with an empty property set, which is what made
252 // every environment and store-size breakdown unqueryable (#793).
253 ( new Lifecycle_Events() )->maybe_refresh_group_properties();
254
255 // The activation funnel's middle step, carrying the feature-adoption
256 // snapshot. One snapshot per view answers "what share of stores
257 // enable X" without an event per settings toggle.
258 $analytics->capture(
259 'admin_landing_viewed',
260 array( 'settings_summary' => ( new Analytics_Profile() )->get_settings_summary() )
261 );
262
263 $is_development = isset( $_ENV['DEVELOPMENT'] )
264 && wp_validate_boolean( sanitize_text_field( wp_unslash( $_ENV['DEVELOPMENT'] ) ) );
265 $url = $is_development ? 'http://localhost:9000/' : 'https://cdn.jsdelivr.net/gh/wcpos/wp-admin-landing@2/assets/';
266
267 // Enqueue the landing page CSS from CDN.
268 wp_enqueue_style(
269 'wcpos-welcome',
270 $url . 'css/welcome.css',
271 array(),
272 PLUGIN_VERSION
273 );
274
275 // Ensure WordPress bundled React and lodash are loaded as dependencies.
276 wp_enqueue_script( 'react' );
277 wp_enqueue_script( 'lodash' );
278
279 // Enqueue the landing page JS from CDN, with React and lodash as dependencies.
280 wp_enqueue_script(
281 'wcpos-welcome',
282 $url . 'js/welcome.js',
283 array(
284 'react',
285 'react-dom',
286 'wp-element',
287 'lodash',
288 ),
289 PLUGIN_VERSION,
290 true
291 );
292
293 // Inject functional data (locale, version, anon_id, experiment
294 // bootstrap flags). The landing bundle owns PostHog initialisation,
295 // flag resolution, and identify ordering (flag-before-identify, spec
296 // §5.1); the plugin must NOT init PostHog or identify here — doing so
297 // shares the bundle's localStorage identity, breaks the anon-bucket
298 // exposure, and double-fires CTA tracking.
299 wp_add_inline_script( 'wcpos-welcome', $this->landing_inline_script(), 'before' );
300 }
301 }
302
303 /**
304 * Generate the inline script that exposes the analytics client.
305 *
306 * When the user has explicitly allowed tracking, loads the PostHog
307 * async SDK, initializes it with the configured token/host, and
308 * identifies the current user + site.
309 *
310 * When consent has not been granted, exposes a no-op stub at
311 * `window.wcpos.posthog` so that future UI event helpers can call
312 * `.capture()` etc. unconditionally without throwing — and without
313 * any network traffic leaving the browser.
314 */
315 public static function get_posthog_inline_script(): string {
316 $analytics = Analytics::instance();
317 $noop_stub = '(function(){var w=window.wcpos=window.wcpos||{};w.posthog={capture:function(){},identify:function(){},group:function(){},register:function(){},reset:function(){},opt_in_capturing:function(){},opt_out_capturing:function(){}};})();';
318
319 if ( ! $analytics->is_enabled() ) {
320 return $noop_stub;
321 }
322
323 $token = wp_json_encode( $analytics->get_token() );
324 $host = wp_json_encode( $analytics->get_host() );
325 $site_id = wp_json_encode( $analytics->get_site_id() );
326 $user_id = wp_json_encode( $analytics->get_distinct_id() );
327
328 // If any value fails to encode (e.g. malformed UTF-8 coming
329 // from a filter override), fall back to the no-op stub rather
330 // than emitting `posthog.init(, { api_host: , ... })`.
331 if ( false === $token || false === $host || false === $site_id || false === $user_id ) {
332 return $noop_stub;
333 }
334
335 // phpcs:disable Generic.Files.LineLength.TooLong -- PostHog snippet is a single line by design.
336 $snippet = <<<JS
337 (function() {
338 var wcpos = window.wcpos = window.wcpos || {};
339 !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
340 function stripUrlProperties(properties) {
341 if (!properties) {
342 return;
343 }
344
345 delete properties['\$current_url'];
346 delete properties['\$host'];
347 delete properties['\$pathname'];
348 delete properties['\$referrer'];
349 delete properties['\$referring_domain'];
350 delete properties['\$initial_current_url'];
351 delete properties['\$initial_host'];
352 delete properties['\$initial_pathname'];
353 delete properties['\$initial_referrer'];
354 delete properties['\$initial_referring_domain'];
355
356 Object.keys(properties).forEach(function(key) {
357 if (key.indexOf('\$session_entry_') === 0 || key.indexOf('\$initial_session_entry_') === 0) {
358 delete properties[key];
359 }
360 });
361 }
362
363 posthog.init(%TOKEN%, {
364 api_host: %HOST%,
365 capture_pageview: false,
366 autocapture: false,
367 persistence: 'localStorage+cookie',
368 disable_session_recording: true,
369 before_send: function(event) {
370 if (!event) {
371 return event;
372 }
373
374 stripUrlProperties(event.properties);
375 stripUrlProperties(event['\$set']);
376 stripUrlProperties(event['\$set_once']);
377
378 return event;
379 }
380 });
381 wcpos.posthog = posthog;
382 var distinctId = %USER_ID%;
383 var siteId = %SITE_ID%;
384 if (distinctId) { posthog.identify(distinctId); }
385 if (siteId) { posthog.group('site', siteId); }
386 })();
387 JS;
388 // phpcs:enable Generic.Files.LineLength.TooLong
389
390 return str_replace(
391 array( '%TOKEN%', '%HOST%', '%USER_ID%', '%SITE_ID%' ),
392 array( $token, $host, $user_id, $site_id ),
393 $snippet
394 );
395 }
396
397 /**
398 * Build an admin-post URL that tracks an upgrade click before redirecting.
399 *
400 * @param string $placement Stable CTA placement identifier.
401 * @param string $destination Final redirect URL.
402 *
403 * @return string
404 */
405 public static function get_upgrade_tracking_url( string $placement, string $destination ): string {
406 return add_query_arg(
407 array(
408 'action' => 'wcpos_track_upgrade_click',
409 'placement' => $placement,
410 'destination' => $destination,
411 '_wpnonce' => wp_create_nonce( 'wcpos_track_upgrade_click' ),
412 ),
413 admin_url( 'admin-post.php' )
414 );
415 }
416
417 /**
418 * Track an upgrade click and return a safe redirect destination.
419 *
420 * @param string $placement Stable CTA placement identifier.
421 * @param string $destination Final redirect URL.
422 *
423 * @return string
424 */
425 public static function track_upgrade_click( string $placement, string $destination ): string {
426 $safe_destination = self::sanitize_upgrade_destination( $destination );
427
428 Analytics::instance()->capture(
429 'pro_link_clicked',
430 array(
431 'placement' => sanitize_key( $placement ),
432 'destination' => $safe_destination,
433 )
434 );
435
436 return $safe_destination;
437 }
438
439 /**
440 * Handle admin-post upgrade click tracking and redirect.
441 */
442 public static function handle_upgrade_click_redirect(): void {
443 check_admin_referer( 'wcpos_track_upgrade_click' );
444
445 if ( ! current_user_can( 'manage_woocommerce_pos' ) ) {
446 wp_die( esc_html__( 'You do not have permission to access this page.', 'woocommerce-pos' ) );
447 }
448
449 $placement = isset( $_GET['placement'] ) ? sanitize_text_field( wp_unslash( $_GET['placement'] ) ) : '';
450 $destination = isset( $_GET['destination'] ) ? sanitize_text_field( wp_unslash( $_GET['destination'] ) ) : '';
451 $redirect_to = self::track_upgrade_click( $placement, $destination );
452
453 wp_safe_redirect( $redirect_to );
454 exit;
455 }
456
457 /**
458 * Generate the inline script for landing page data.
459 *
460 * Always emits functional data (locale, version, pro status).
461 * Merges in store profile and updates-server config only when
462 * the user has explicitly allowed tracking.
463 *
464 * @return string
465 */
466 private function landing_inline_script(): string {
467 $json_flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
468 $profile = new Landing_Profile();
469 $data = $profile->get_functional_data();
470
471 // Send gate: the persisted answer, not the filtered read view.
472 $consent = Settings::instance()->raw_tracking_consent();
473 if ( 'allowed' === $consent ) {
474 $data = array_merge( $data, $profile->get_consented_data() );
475 }
476
477 $encoded = wp_json_encode( $data, $json_flags );
478
479 if ( false === $encoded ) {
480 Logger::error( 'Landing data JSON encoding failed: ' . json_last_error_msg() );
481 $encoded = '{}';
482 }
483
484 return \sprintf(
485 'var wcpos = wcpos || {}; wcpos.landing = %s;',
486 $encoded
487 );
488 }
489
490 /**
491 * Print a tiny global click tracker for PHP-rendered admin upsell links.
492 */
493 public function print_upgrade_click_tracking_script(): void {
494 $nonce = wp_create_nonce( 'wcpos_track_upgrade_click' );
495 ?>
496 <script>
497 (function() {
498 if (!window.ajaxurl) {
499 return;
500 }
501
502 document.addEventListener('click', function(event) {
503 var target = event.target;
504 if (!target || !target.closest) {
505 return;
506 }
507
508 var link = target.closest('[data-wcpos-upgrade-placement]');
509 if (!link) {
510 return;
511 }
512
513 var placement = link.getAttribute('data-wcpos-upgrade-placement');
514 var destination = link.getAttribute('href');
515 if (!placement || !destination || !window.navigator || !window.navigator.sendBeacon) {
516 return;
517 }
518
519 var data = new URLSearchParams();
520 data.set('action', 'wcpos_track_upgrade_click_ajax');
521 data.set('placement', placement);
522 data.set('destination', destination);
523 data.set('_ajax_nonce', '<?php echo esc_js( $nonce ); ?>');
524 window.navigator.sendBeacon(window.ajaxurl, data);
525 });
526 })();
527 </script>
528 <?php
529 }
530
531 /**
532 * AJAX handler for admin upgrade click tracking.
533 */
534 public static function handle_upgrade_click_ajax(): void {
535 check_ajax_referer( 'wcpos_track_upgrade_click' );
536
537 if ( ! current_user_can( 'manage_woocommerce_pos' ) ) {
538 wp_send_json_error( 'Unauthorized', 403 );
539 }
540
541 $placement = isset( $_POST['placement'] ) ? sanitize_text_field( wp_unslash( $_POST['placement'] ) ) : '';
542 $destination = isset( $_POST['destination'] ) ? sanitize_text_field( wp_unslash( $_POST['destination'] ) ) : '';
543
544 self::track_upgrade_click( $placement, $destination );
545
546 wp_send_json_success();
547 }
548
549 /**
550 * Render the Template Gallery SPA mount point.
551 */
552 public function render_gallery_page(): void {
553 echo '<div class="wrap"><div id="wcpos-template-gallery"></div></div>';
554 }
555
556 /**
557 * Enqueue the Template Gallery SPA assets.
558 */
559 public function enqueue_gallery_assets(): void {
560 $is_development = isset( $_ENV['DEVELOPMENT'] )
561 && wp_validate_boolean( sanitize_text_field( wp_unslash( $_ENV['DEVELOPMENT'] ) ) );
562 $dir = $is_development ? 'build' : 'assets';
563
564 wp_enqueue_style(
565 'wcpos-template-gallery-styles',
566 PLUGIN_URL . $dir . '/css/template-gallery.css',
567 array(),
568 PLUGIN_VERSION
569 );
570
571 wp_enqueue_script(
572 'wcpos-template-gallery',
573 PLUGIN_URL . $dir . '/js/template-gallery.js',
574 array( 'react', 'react-dom', 'wp-api-fetch', \WCPOS\WooCommercePOS\Admin::API_FETCH_METHOD_PARAM_HANDLE, 'wp-url' ),
575 PLUGIN_VERSION,
576 true
577 );
578
579 wp_add_inline_script( 'wcpos-template-gallery', $this->gallery_inline_script(), 'before' );
580 }
581
582 /**
583 * Generate the inline script for gallery data.
584 */
585 private function gallery_inline_script(): string {
586 $json_encode_flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
587
588 return \sprintf(
589 'var wcpos = wcpos || {}; wcpos.templateGallery = { isProActive: %s, adminUrl: %s, hasPosOrders: %s, previewBaseUrl: %s }; wcpos.translationVersion = %s;',
590 wp_json_encode( wcpos_is_pro_active(), $json_encode_flags ),
591 wp_json_encode( untrailingslashit( admin_url() ), $json_encode_flags ),
592 wp_json_encode(
593 (bool) wc_get_orders(
594 array(
595 'limit' => 1,
596 'return' => 'ids',
597 'status' => array( 'completed', 'processing', 'on-hold', 'pending' ),
598 'created_via' => 'woocommerce-pos',
599 )
600 ),
601 $json_encode_flags
602 ),
603 wp_json_encode( PLUGIN_URL . 'assets/img/template-gallery/previews', $json_encode_flags ),
604 wp_json_encode( TRANSLATION_VERSION, $json_encode_flags )
605 );
606 }
607
608 /**
609 * Redirect the old CPT list page (edit.php?post_type=wcpos_template) to the Gallery SPA.
610 *
611 * Only redirects the list view, not the individual post editor.
612 */
613 public function redirect_template_list_page(): void {
614 global $pagenow;
615
616 if (
617 'edit.php' === $pagenow
618 && isset( $_GET['post_type'] )
619 && 'wcpos_template' === $_GET['post_type']
620 && ! isset( $_GET['post_status'] )
621 ) {
622 wp_safe_redirect( admin_url( 'admin.php?page=wcpos-templates' ) );
623 exit;
624 }
625 }
626
627 /**
628 * Keep the POS menu expanded and Templates submenu highlighted when editing a template.
629 *
630 * @param string $parent_file The parent file.
631 *
632 * @return string
633 */
634 public function highlight_templates_menu( $parent_file ) {
635 global $current_screen, $submenu_file;
636
637 if ( isset( $current_screen->post_type ) && 'wcpos_template' === $current_screen->post_type ) {
638 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
639 $submenu_file = 'wcpos-templates';
640 $parent_file = PLUGIN_NAME;
641 }
642
643 return $parent_file;
644 }
645
646 /**
647 * Sanitize an upgrade redirect destination to trusted hosts only.
648 *
649 * @param string $destination Candidate destination URL.
650 *
651 * @return string
652 */
653 private static function sanitize_upgrade_destination( string $destination ): string {
654 $fallback = 'https://wcpos.com/pro';
655 $destination = rawurldecode( $destination );
656 $parsed_url = wp_parse_url( $destination );
657 $parsed_admin = wp_parse_url( admin_url() );
658 $allowed_hosts = array_filter(
659 array(
660 $parsed_admin['host'] ?? '',
661 'wcpos.com',
662 'www.wcpos.com',
663 )
664 );
665
666 if ( empty( $parsed_url['host'] ) || ! \in_array( $parsed_url['host'], $allowed_hosts, true ) ) {
667 return $fallback;
668 }
669
670 return esc_url_raw( $destination );
671 }
672 }
673