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

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

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