PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / admin / notices.php

notices.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/admin/notices.php

941 lines 35.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin notice handler.
4 */
5
6 namespace StoreEngine\Admin;
7
8 use StoreEngine\ActionQueue;
9 use StoreEngine\Installer;
10 use StoreEngine\Traits\Singleton;
11 use StoreEngine\Utils\CheckoutFields;
12 use StoreEngine\Utils\Formatting;
13 use StoreEngine\Utils\Helper;
14 use StoreEngine\Utils\ShippingUtils;
15 use StoreEngine\Utils\TaxUtil;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 class Notices {
22 use Singleton;
23
24 const TYPE_INFO = 'info';
25
26 const TYPE_SUCCESS = 'success';
27
28 const TYPE_WARNING = 'warning';
29
30 const TYPE_ERROR = 'error';
31
32 private static array $notices = [];
33
34 private static string $action = 'storeengine/hide-notice';
35
36 private static string $option_name = 'storeengine_notices';
37
38 protected function __construct() {
39
40 add_action( 'init', [ $this, 'dispatch_notices' ] );
41 add_filter( 'storeengine/backend_scripts_data', [ $this, 'add_notice_data' ] );
42 add_action( 'admin_notices', [ $this, 'render_notices' ] );
43
44 // Handle dismissal requests.
45 add_action( 'admin_init', [ $this, 'handle_admin_request' ], 20 );
46 add_action( 'wp_ajax_' . self::$action, [ $this, 'handle_admin_request' ] );
47 }
48
49 public function dispatch_notices() {
50 Notices::show_get_pro_nag();
51
52 if ( empty( get_option( 'permalink_structure' ) ) ) {
53 $this->add_permalink_notice();
54 }
55
56
57 if ( ! Installer::is_new_install() ) {
58 self::add_ablocks_notice();
59 }
60
61 if ( ! self::is_uploads_directory_protected() ) {
62 self::add_unprotected_uploads_directory_notice();
63 }
64
65 $core_pages = [
66 'shop_page',
67 'cart_page',
68 'checkout_page',
69 'thankyou_page',
70 'dashboard_page',
71 ];
72 $contents = Helper::get_store_page_contents();
73 $missing_pages = [];
74
75 foreach ( $core_pages as $page ) {
76 if ( ! Helper::get_settings( $page ) || ! get_post( Helper::get_settings( $page ) ) ) {
77 $missing_pages[ $page ] = $contents[ $page ]['title'];
78 }
79 }
80
81 // Auto-create any missing core pages (e.g. when the setup wizard was
82 // skipped/cancelled) so the store works without the merchant having to
83 // click "Generate Missing Pages" by hand.
84 $missing_pages = $this->maybe_autogenerate_core_pages( $missing_pages );
85
86 if ( ! empty( $missing_pages ) ) {
87 self::add_notice( 'missing_core_pages', [
88 'type' => self::TYPE_ERROR,
89 'icon' => 'info',
90 'alt' => false,
91 'large' => false,
92 'classes' => '',
93 'title' => '',
94 'message' => sprintf(
95 // translators: %s. Missing pages.
96 __( '<h3>Some core pages are missing.</h3><p>One or more required pages were not found: <code>%s</code>.</p>', 'storeengine' ),
97 implode( ', ', $missing_pages )
98 ),
99 'button_text' => __( 'Generate Missing Pages', 'storeengine' ),
100 'button_action' => admin_url( 'admin.php?page=storeengine-tools&path=pages&regenerate=1' ),
101 'dismissible' => false,
102 ] );
103 }
104
105 self::add_shipping_address_fields_notice();
106 self::add_postcode_required_notice();
107 self::add_unconfigured_gateways_notice();
108 self::add_catalog_mode_notice();
109 }
110
111 /**
112 * Explain why prices / add-to-cart may be missing when Catalog Mode is on.
113 *
114 * Catalog Mode intentionally hides prices and/or the add-to-cart button, so a
115 * merchant who doesn't know it's enabled has no way to understand why their
116 * store looks "broken". Show a persistent (non-dismissible) info notice while
117 * it's active, with a shortcut to its settings — it disappears the moment the
118 * mode is switched off.
119 */
120 public static function add_catalog_mode_notice() {
121 if ( self::has_notice( 'catalog_mode_active' ) ) {
122 return;
123 }
124
125 // Catalog Mode only actually runs while its addon is active. The
126 // `enabled` setting persists after the addon is deactivated, so gate on
127 // the addon too — otherwise the notice lingers even though nothing is
128 // being hidden on the storefront.
129 if ( ! Helper::get_addon_active_status( 'catalog-mode' ) ) {
130 return;
131 }
132
133 $catalog = (array) Helper::get_settings( 'catalog_mode' );
134
135 if ( empty( $catalog['enabled'] ) ) {
136 return;
137 }
138
139 // Describe what's actually hidden so the message matches the config.
140 $hidden = [];
141 if ( ! empty( $catalog['disable_price'] ) ) {
142 $hidden[] = __( 'prices', 'storeengine' );
143 }
144 if ( ! empty( $catalog['disable_cart_checkout'] ) || 'all' === ( $catalog['hide_add_to_cart_in'] ?? '' ) ) {
145 $hidden[] = __( 'the add-to-cart button', 'storeengine' );
146 }
147 $hidden_text = empty( $hidden )
148 ? __( 'some storefront elements', 'storeengine' )
149 : implode( __( ' and ', 'storeengine' ), $hidden );
150
151 self::add_notice( 'catalog_mode_active', [
152 'type' => self::TYPE_INFO,
153 'dismissible' => false,
154 'message' => sprintf(
155 /* translators: %s: what catalog mode is hiding, e.g. "prices and the add-to-cart button". */
156 __( '<h3>Catalog Mode is on.</h3><p>Your store is running as a catalog, so %s are hidden by design. If that isn\'t what you expected, turn Catalog Mode off or adjust it in its settings.</p>', 'storeengine' ),
157 $hidden_text
158 ),
159 'button_text' => __( 'Catalog Mode settings', 'storeengine' ),
160 'button_action' => admin_url( 'admin.php?page=storeengine-settings&path=catalog-mode' ),
161 ] );
162 }
163
164 /**
165 * Prompt the merchant to finish connecting the payment methods they enabled.
166 *
167 * A payment gateway can be switched on (e.g. from the setup wizard's Payments
168 * step) before its API credentials are entered — in that state it can't take
169 * a single payment. Surface a dismissible notice listing every enabled-but-
170 * unconfigured gateway with a shortcut to the Payments settings. The notice
171 * self-clears the moment each gateway reports it no longer `needs_setup()`.
172 */
173 public static function add_unconfigured_gateways_notice() {
174 if ( self::has_notice( 'payment_gateways_need_setup' ) ) {
175 return;
176 }
177
178 $gateways = Helper::get_payment_gateways()->payment_gateways();
179
180 if ( empty( $gateways ) ) {
181 return;
182 }
183
184 $pending = [];
185 foreach ( $gateways as $gateway ) {
186 if ( method_exists( $gateway, 'needs_setup' ) && $gateway->needs_setup() ) {
187 $pending[] = '<strong>' . esc_html( $gateway->get_method_title() ) . '</strong>';
188 }
189 }
190
191 if ( empty( $pending ) ) {
192 return;
193 }
194
195 $names = implode( ', ', $pending );
196
197 self::add_notice( 'payment_gateways_need_setup', [
198 'type' => self::TYPE_WARNING,
199 'dismissible' => true,
200 'message' => sprintf(
201 /* translators: %s: comma-separated payment method names, already wrapped in <strong> tags. */
202 _n(
203 '<h3>Connect your payment method to start selling.</h3><p>You enabled %s during setup, but it still needs its API credentials before it can accept payments.</p>',
204 '<h3>Connect your payment methods to start selling.</h3><p>You enabled %s during setup, but they still need their API credentials before they can accept payments.</p>',
205 count( $pending ),
206 'storeengine'
207 ),
208 $names
209 ),
210 'button_text' => __( 'Configure payments', 'storeengine' ),
211 'button_action' => admin_url( 'admin.php?page=storeengine-settings&path=payment-method' ),
212 ] );
213 }
214
215 /**
216 * Make sure the store's core pages always exist.
217 *
218 * A fresh install offers the setup wizard, whose "Setup Pages" step creates
219 * these pages (optionally with a slug prefix or mapped to existing
220 * store pages). If the admin skips or cancels the wizard, the store is
221 * left without a Cart, Checkout, etc. and nothing works — surfaced only as a
222 * "Some core pages are missing" notice they have to act on manually.
223 *
224 * Instead, silently create any missing core pages with their default slugs
225 * the moment we notice they're gone. This runs everywhere EXCEPT the wizard
226 * screen itself, which still owns first-run creation so we never pre-empt its
227 * prefix / page-mapping choices. {@see Helper::create_page()} is
228 * idempotent (reuses existing/trashed pages, skips ones already configured),
229 * so this only fills the gaps.
230 *
231 * @param array $missing Missing pages as setting-key => title.
232 *
233 * @return array The pages still missing after the attempt (usually empty).
234 */
235 private function maybe_autogenerate_core_pages( array $missing ): array {
236 if ( empty( $missing ) ) {
237 return $missing;
238 }
239
240 // Only a store manager on a normal admin page should trigger creation.
241 // Skip AJAX / REST / cron, and the setup wizard (it creates them itself).
242 $on_setup_screen = isset( $_GET['page'] ) && Setup::PAGE_ID === sanitize_key( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
243 if (
244 ! is_admin() ||
245 wp_doing_ajax() ||
246 ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ||
247 $on_setup_screen ||
248 ! current_user_can( 'manage_options' )
249 ) {
250 return $missing;
251 }
252
253 // Don't hammer create-page queries every request if creation keeps
254 // failing; retry at most hourly. A successful run resolves the missing
255 // state so the lock never matters.
256 if ( get_transient( 'storeengine_autogenerate_core_pages_lock' ) ) {
257 return $missing;
258 }
259 set_transient( 'storeengine_autogenerate_core_pages_lock', 'yes', HOUR_IN_SECONDS );
260
261 // Create any missing pages with default (no-prefix) slugs and refresh
262 // the in-memory settings so the re-check below sees the new IDs.
263 Helper::create_initial_pages();
264 Settings::load_settings();
265
266 $contents = Helper::get_store_page_contents();
267 $still_missing = [];
268 foreach ( array_keys( $missing ) as $page ) {
269 if ( ! Helper::get_settings( $page ) || ! get_post( Helper::get_settings( $page ) ) ) {
270 $still_missing[ $page ] = $contents[ $page ]['title'];
271 }
272 }
273
274 // Everything created — clear the lock so a future deletion is handled
275 // promptly instead of waiting out the hour.
276 if ( empty( $still_missing ) ) {
277 delete_transient( 'storeengine_autogenerate_core_pages_lock' );
278 }
279
280 return $still_missing;
281 }
282
283 /**
284 * Warn the admin when shipping and/or tax is active but the Postcode field
285 * customers rely on to trigger those calculations isn't collected.
286 *
287 * Both shipping-zone matching ({@see ShippingUtils}) and tax-rate matching
288 * ({@see \StoreEngine\Classes\Tax}) look the rate up by the destination
289 * postcode. If the relevant Postcode checkout field is disabled — or merely
290 * optional, so a shopper can leave it blank — postcode-based rates cannot be
291 * matched and the order silently gets the wrong (or zero) shipping/tax.
292 *
293 * Shipping's own postcode is auto-promoted to required whenever the field is
294 * enabled and shipping is live (see CheckoutFields::all()), so the real gaps
295 * this catches are: (a) the field turned off entirely, and (b) billing-based
296 * tax, where the billing Postcode has no such promotion and can be left
297 * optional.
298 */
299 public static function add_postcode_required_notice() {
300 if ( self::has_notice( 'postcode_field_not_collected' ) ) {
301 return;
302 }
303
304 $shipping_live = ShippingUtils::is_shipping_enabled() && ShippingUtils::get_shipping_methods_count( true ) > 0;
305 $tax_enabled = TaxUtil::is_tax_enabled();
306
307 // Nothing depends on the postcode unless shipping or tax is in play.
308 if ( ! $shipping_live && ! $tax_enabled ) {
309 return;
310 }
311
312 // Map each postcode field that actually feeds a calculation to the
313 // feature(s) that need it. Shipping always resolves against the shipping
314 // address; tax follows the "Calculate tax based on" setting (a `base`
315 // address uses the store's own postcode, so nothing to collect there).
316 $needed = [];
317 if ( $shipping_live ) {
318 $needed['shipping_post_code'][] = __( 'shipping', 'storeengine' );
319 }
320 if ( $tax_enabled ) {
321 $tax_based_on = TaxUtil::tax_based_on();
322 if ( 'billing' === $tax_based_on ) {
323 $needed['billing_post_code'][] = __( 'tax', 'storeengine' );
324 } elseif ( 'shipping' === $tax_based_on ) {
325 $needed['shipping_post_code'][] = __( 'tax', 'storeengine' );
326 }
327 }
328
329 // Collect the fields that aren't reliably captured — disabled outright or
330 // enabled-but-optional. Uses resolved values so shipping's auto-promotion
331 // isn't reported as a false positive.
332 $problems = [];
333 foreach ( $needed as $field_id => $features ) {
334 $row = CheckoutFields::get( $field_id );
335 $enabled = $row ? ! empty( $row['enabled'] ) : true;
336 $required = $row ? ! empty( $row['required'] ) : true;
337
338 if ( $enabled && $required ) {
339 continue;
340 }
341
342 $label = 'billing_post_code' === $field_id
343 ? __( 'Billing Postcode', 'storeengine' )
344 : __( 'Shipping Postcode', 'storeengine' );
345
346 // Join the affected features ("shipping", "tax") into a readable phrase.
347 // There are at most two, so a simple " and " join is enough.
348 $feature_text = implode( __( ' and ', 'storeengine' ), $features );
349
350 $problems[] = sprintf(
351 /* translators: 1: checkout field label, 2: affected features e.g. "shipping and tax" */
352 __( '<strong>%1$s</strong> (used for %2$s)', 'storeengine' ),
353 $label,
354 $feature_text
355 );
356 }
357
358 if ( empty( $problems ) ) {
359 return;
360 }
361
362 self::add_notice( 'postcode_field_not_collected', [
363 'type' => self::TYPE_WARNING,
364 'dismissible' => true,
365 'message' => sprintf(
366 /* translators: %s: list of postcode fields that are not required, already wrapped in <li> items. */
367 __( '<h3>Shipping &amp; tax may be calculated incorrectly.</h3><p>StoreEngine matches shipping and tax rates by postcode/ZIP, but the field customers need isn\'t being collected as required:</p><ul style="list-style:disc;margin-left:20px;">%s</ul><p>Enable it and mark it <strong>required</strong> under Checkout Fields so rates can always be matched.</p>', 'storeengine' ),
368 '<li>' . implode( '</li><li>', $problems ) . '</li>'
369 ),
370 'button_text' => __( 'Review Checkout Fields', 'storeengine' ),
371 'button_action' => admin_url( 'admin.php?page=storeengine-settings&path=checkout-fields' ),
372 ] );
373 }
374
375 /**
376 * Warn the admin when shipping is active but customers can't enter the
377 * address details needed to calculate it.
378 *
379 * Shipping zone matching and rate calculation rely on a "full" shipping
380 * address. When BOTH the State / Region and Postal Code checkout fields are
381 * disabled, the only location detail left is the City — so an empty City
382 * (or a store that expected to collect a ZIP) silently yields zero shipping
383 * options with no feedback to the shopper. Surface that misconfiguration so
384 * it isn't discovered the hard way at checkout.
385 */
386 public static function add_shipping_address_fields_notice() {
387 if ( self::has_notice( 'shipping_address_fields_disabled' ) ) {
388 return;
389 }
390
391 // Only relevant when shipping is actually in use (enabled + at least one live method).
392 if ( ! ShippingUtils::is_shipping_enabled() || ShippingUtils::get_shipping_methods_count( true ) < 1 ) {
393 return;
394 }
395
396 $state = CheckoutFields::get( 'shipping_state' );
397 $post = CheckoutFields::get( 'shipping_post_code' );
398
399 // Unsaved fields default to enabled, so a null row is treated as "available".
400 $state_enabled = $state ? ! empty( $state['enabled'] ) : true;
401 $post_enabled = $post ? ! empty( $post['enabled'] ) : true;
402
403 // At least one of State / Postal Code is available — nothing to warn about.
404 if ( $state_enabled || $post_enabled ) {
405 return;
406 }
407
408 self::add_notice( 'shipping_address_fields_disabled', [
409 'type' => self::TYPE_WARNING,
410 'dismissible' => true,
411 'message' => __( '<h3>Shipping may not work at checkout.</h3><p>You have shipping methods enabled, but both the <strong>State / Region</strong> and <strong>Postal Code</strong> checkout fields are turned off. StoreEngine uses these to match shipping zones and calculate shipping costs — with both disabled, customers may see no shipping options. Enable at least one of them under Checkout Fields.</p>', 'storeengine' ),
412 'button_text' => __( 'Enable Address Fields', 'storeengine' ),
413 'button_action' => admin_url( 'admin.php?page=storeengine-settings&path=checkout-fields' ),
414 ] );
415 }
416
417 private function respond_error( $message, $code = 403 ) {
418 if ( ! wp_doing_ajax() ) {
419 wp_die( esc_html( $message ), esc_html__( 'Action failed.', 'storeengine' ), [ 'response' => $code ] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
420 }
421
422 wp_send_json_error( $message, $code );
423 }
424
425 private function respond_success( $message = null ) {
426 if ( ! wp_doing_ajax() ) {
427 wp_safe_redirect( remove_query_arg( [ 'action', 'security', 'notice' ] ) );
428 die();
429 }
430
431 wp_send_json_success( $message );
432 }
433
434 public function handle_admin_request() {
435 if ( isset( $_REQUEST['action'], $_REQUEST['security'] ) && ! empty( $_REQUEST['notice'] ) && self::$action === wp_unslash( $_REQUEST['action'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
436 if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_REQUEST['security'] ) ), 'storeengine_nonce' ) ) {
437 $this->respond_error( __( 'Action failed. Please refresh the page and retry.', 'storeengine' ) );
438 }
439
440 $notice = sanitize_text_field( wp_unslash( $_REQUEST['notice'] ) );
441 $capability = apply_filters( 'storeengine/admin_notice/dismissal_capability', 'manage_options', $notice );
442
443 if ( ! current_user_can( $capability ) ) {
444 $this->respond_error( __( "You don't have permission to do this.", 'storeengine' ), 403 );
445 }
446
447 if ( ! self::has_notice( $notice ) ) {
448 $this->respond_success(); // Return the user. Notice may have already been removed (different tab).
449 }
450
451 self::remove_notice( $notice );
452
453 $this->respond_success();
454 }
455
456 }
457
458 public function add_notice_data( array $data ): array {
459 return array_merge( $data, [ 'admin_notices' => array_values( self::get_notices() ) ] );
460 }
461
462 public function render_notices() {
463 global $pagenow;
464
465 $notices = self::get_notices();
466
467 if ( ! empty( $notices ) ) {
468 $has_dismissible = false;
469 foreach ( $notices as $notice ) {
470 $classes = array_merge(
471 [
472 'storeengine-admin-notice',
473 'storeengine-admin-notice-' . $notice['type'],
474 'notice',
475 str_replace( [ '.' ], '-', $pagenow ),
476 'storeengine-admin-notice--' . $notice['key'],
477
478 ],
479 array_filter( $notice['classes'] )
480 );
481
482 switch ( $notice['type'] ) {
483 case 'error':
484 $classes[] = 'notice-error';
485 break;
486 case 'warning':
487 $classes[] = 'notice-warning';
488 break;
489 case 'success':
490 $classes[] = 'notice-success';
491 break;
492 case 'info':
493 default:
494 $classes[] = 'notice-info';
495 break;
496 }
497
498 if ( $notice['large'] ) {
499 $classes[] = 'notice-large';
500 }
501
502 if ( $notice['alt'] ) {
503 $classes[] = 'notice-alt';
504 }
505
506 if ( $notice['dismissible'] ) {
507 $classes[] = 'is-dismissible';
508 if ( ! $has_dismissible ) {
509 $has_dismissible = true;
510 }
511 }
512
513 $classes = implode( ' ', $classes );
514
515 include __DIR__ . '/notice-html.php';
516 }
517
518 // Always enqueue — the footer script wires dismissal AND collapses
519 // 3+ notices behind a grouping "bell". ( $has_dismissible retained
520 // for clarity; grouping needs to run regardless. )
521 add_action( 'admin_footer', [ $this, 'add_notice_script' ], 100 );
522 unset( $has_dismissible );
523 }
524 }
525
526 /**
527 * Inline SVG for the small icon set used by admin notices and the grouping
528 * bell. This replaces the icon-font glyphs so the always-loaded admin path
529 * no longer needs the full icon-font stylesheet + font files.
530 *
531 * The returned <svg> keeps the `storeengine-icon` class and is sized in `em`
532 * with `currentColor`, so the existing size/color CSS keeps working. Unknown
533 * names fall back to "info". The markup is built from a static whitelist
534 * (only the resolved name is interpolated, and it is escaped), so callers can
535 * echo the return value directly.
536 *
537 * @param string $name Icon name (info|success|warning|error|close|notification).
538 * @return string Inline SVG markup.
539 */
540 public static function get_svg_icon( string $name ): string {
541 $paths = [
542 'info' => '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
543 'success' => '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
544 'warning' => '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
545 'error' => '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
546 'close' => '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
547 'notification' => '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
548 ];
549
550 // Legacy icon-font names that map onto one of the SVGs above.
551 $aliases = [
552 'check-circle' => 'success',
553 'notify-success' => 'success',
554 'notify-warning' => 'warning',
555 'notify-error' => 'error',
556 'notify-information' => 'info',
557 'info-circle' => 'info',
558 'info-primary' => 'info',
559 ];
560
561 if ( isset( $aliases[ $name ] ) ) {
562 $name = $aliases[ $name ];
563 }
564
565 if ( ! isset( $paths[ $name ] ) ) {
566 $name = 'info';
567 }
568
569 return sprintf(
570 '<svg class="storeengine-icon storeengine-icon--%1$s" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">%2$s</svg>',
571 esc_attr( $name ),
572 $paths[ $name ]
573 );
574 }
575
576 public function add_notice_script() {
577 $labels = [
578 'one' => __( '1 thing needs your attention', 'storeengine' ),
579 // translators: %d is the number of admin notices needing attention.
580 'many' => __( '%d things need your attention', 'storeengine' ),
581 'expand' => __( 'Expand', 'storeengine' ),
582 'collapse' => __( 'Collapse', 'storeengine' ),
583 ];
584 ?>
585 <script>
586 ( ( $ ) => {
587 const L = <?php echo wp_json_encode( $labels ); ?>;
588 const label = ( n ) => n === 1 ? L.one : L.many.replace( '%d', n );
589
590 // --- Dismiss ---
591 $( document ).on( 'click', '.storeengine-admin-notice-close.notice-dismiss', function ( event ) {
592 event.preventDefault();
593 const notice = $( this ).data( 'notice' );
594 const $card = $( this ).closest( '.storeengine-admin-notice' );
595 $.post( '<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>', {
596 notice: notice,
597 action: '<?php echo esc_js( self::$action ); ?>',
598 security: '<?php echo esc_js( wp_create_nonce( 'storeengine_nonce' ) ); ?>',
599 }, function () {
600 $card.slideUp( 150, function () {
601 $card.remove();
602 refreshGroup();
603 } );
604 } );
605 } );
606
607 // Keep the group's count/label in sync after a dismissal.
608 function refreshGroup() {
609 const $g = $( '.storeengine-notice-group' );
610 if ( ! $g.length ) { return; }
611 const n = $g.find( '.storeengine-admin-notice' ).length;
612 if ( n === 0 ) { $g.remove(); return; }
613 $g.find( '.storeengine-notice-group__count' ).text( n );
614 $g.find( '.storeengine-notice-group__label' ).text( label( n ) );
615 }
616
617 // --- Group 3+ notices behind a bell ---
618 $( function () {
619 // Only the classic, top-of-page notices — never the ones the
620 // React app renders inside its own root.
621 const $notices = $( '.storeengine-admin-notice' ).filter( function () {
622 return ! $( this ).closest( '#storeengine-admin, #storeenginewrap, #storeengine_setup_screen_wrap, .storeengine-notice-group' ).length;
623 } );
624 if ( $notices.length < 3 ) { return; }
625
626 const count = $notices.length;
627 const $group = $( '<div class="storeengine-notice-group"></div>' );
628 const $head = $(
629 '<button type="button" class="storeengine-notice-group__head" aria-expanded="false">' +
630 '<span class="storeengine-notice-group__bell">' + <?php echo wp_json_encode( self::get_svg_icon( 'notification' ) ); ?> +
631 '<span class="storeengine-notice-group__count">' + count + '</span></span>' +
632 '<b class="storeengine-notice-group__label"></b>' +
633 '<span class="storeengine-notice-group__chev">' + L.expand + '</span>' +
634 '</button>'
635 );
636 const $list = $( '<div class="storeengine-notice-group__list" hidden></div>' );
637
638 $head.find( '.storeengine-notice-group__label' ).text( label( count ) );
639 $notices.first().before( $group );
640 $group.append( $head ).append( $list );
641 $notices.each( function () { $list.append( this ); } );
642
643 $head.on( 'click', function () {
644 const open = $group.hasClass( 'is-open' );
645 $group.toggleClass( 'is-open', ! open );
646 $list.prop( 'hidden', open );
647 $head.attr( 'aria-expanded', ( ! open ).toString() );
648 $head.find( '.storeengine-notice-group__chev' ).text( open ? L.expand : L.collapse );
649 } );
650 } );
651 } )( jQuery );
652 </script>
653 <?php
654 }
655
656 /**
657 * Add notice.
658 *
659 * Notices are frequently registered while addons boot on `plugins_loaded`,
660 * before WordPress has loaded textdomains. Resolving a translatable string
661 * that early triggers WP 6.7+'s `_load_textdomain_just_in_time` _doing_it_wrong
662 * warning. To register a notice safely from that window, pass a {@see \Closure}
663 * for any translatable field (`message`, `title`, `button_text`, …); the
664 * closure is resolved on `init`, after textdomains are available. Plain-string
665 * callers keep working unchanged — and are still deferred to `init` so the
666 * registration order is consistent.
667 *
668 * @param string $notice_name
669 * @param array $args {
670 *
671 * @type string $type Notice type.
672 * @type string $icon Icon.
673 * @type bool $alt Render alt style (wp notice-alt class)
674 * @type string|string[] $classes Extra class.
675 * @type string|\Closure $title Optional Title.
676 * @type string|\Closure $message The Message.
677 * @type string|\Closure $button_text Extra button text/label
678 * @type string $button_action Extra button action url
679 * @type bool $dismissible Show dismissible button.
680 * }
681 *
682 * @return void
683 */
684 public static function add_notice( string $notice_name, array $args ) {
685 // Defer registration until `init` when called too early, so any deferred
686 // (Closure) translatable fields resolve after textdomains are loaded.
687 // Priority 5 keeps notices in place before dispatch_notices() (prio 10)
688 // and admin_notices rendering.
689 if ( ! did_action( 'init' ) ) {
690 add_action( 'init', static function () use ( $notice_name, $args ) {
691 self::register_notice( $notice_name, $args );
692 }, 5 );
693
694 return;
695 }
696
697 self::register_notice( $notice_name, $args );
698 }
699
700 private static function register_notice( string $notice_name, array $args ) {
701 // Resolve any deferred (Closure) values now that textdomains are available.
702 foreach ( $args as $key => $value ) {
703 if ( $value instanceof \Closure ) {
704 $args[ $key ] = $value();
705 }
706 }
707
708 $args = wp_parse_args( $args, [
709 'type' => self::TYPE_INFO,
710 'icon' => 'info',
711 'alt' => false,
712 'large' => false,
713 'classes' => '',
714 'title' => '',
715 'message' => '',
716 'button_text' => '',
717 'button_action' => '',
718 'button_class' => '',
719 'button_target' => '_self',
720 'dismissible' => false,
721 ] );
722
723 // This is final.
724 $args['key'] = $notice_name;
725
726 $types = [ self::TYPE_INFO, self::TYPE_SUCCESS, self::TYPE_WARNING, self::TYPE_ERROR ];
727
728 if ( ! in_array( $args['type'], $types, true ) ) {
729 $args['type'] = 'info';
730 }
731
732 $args['alt'] = (bool) $args['alt'];
733 $args['large'] = (bool) $args['large'];
734 $args['dismissible'] = (bool) $args['dismissible'];
735 $args['classes'] = $args['classes'] && ! is_array( $args['classes'] ) ? explode( ' ', $args['classes'] ) : [];
736 $args['title'] = esc_html( $args['title'] );
737 $args['message'] = wp_kses_post( wpautop( $args['message'] ) );
738 $args['button_text'] = esc_html( $args['button_text'] );
739 $args['button_action'] = sanitize_url( $args['button_action'] );
740 $args['has_buttons'] = $args['dismissible'] || ( $args['button_text'] && $args['button_action'] );
741
742 self::$notices[ $notice_name ] = $args;
743 }
744
745 public static function remove_notice( string $notice_name ) {
746 $notice = self::$notices[ $notice_name ];
747
748 unset( self::$notices[ $notice_name ] );
749
750 if ( 'uploads_directory_is_unprotected' === $notice_name ) {
751 // this will hide the notice for next 12 hours.
752 set_transient( '_storeengine_upload_directory_status', 'protected', DAY_IN_SECONDS );
753 }
754
755 if ( $notice['dismissible'] ) {
756 // Hide for the current user only.
757 update_user_meta( get_current_user_id(), 'dismissed_' . $notice_name . '_notice', 'yes' );
758 }
759
760 do_action( 'storeengine/admin_notice/hide_' . $notice_name . '_notice' );
761 }
762
763 protected static function is_user_dismissed( string $notice_name ): bool {
764 return Formatting::string_to_bool( get_user_meta( get_current_user_id(), 'dismissed_' . $notice_name . '_notice', true ) );
765 }
766
767 protected static function remove_user_dismissal( string $notice_name ) {
768 delete_user_meta( get_current_user_id(), 'dismissed_' . $notice_name . '_notice' );
769 }
770
771 protected function maybe_inside_dashboard(): bool {
772 return isset( $_REQUEST['page'] ) && str_starts_with( wp_unslash( $_REQUEST['page'] ), 'storeengine-' ) || wp_is_serving_rest_request(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
773 }
774
775 public static function get_notices(): array {
776 return array_filter( self::$notices, fn( $notice ) => ! self::is_user_dismissed( $notice ), ARRAY_FILTER_USE_KEY );
777 }
778
779 public static function remove_all_notices() {
780 self::$notices = [];
781 }
782
783 public static function has_notice( string $notice_name ): bool {
784 return isset( self::$notices[ $notice_name ] );
785 }
786
787 public function add_permalink_notice() {
788 if ( self::has_notice( 'update_permalink_settings' ) ) {
789 return;
790 }
791
792 self::add_notice( 'update_permalink_settings', [
793 'type' => 'warning',
794 'message' => __( 'Your permalink settings is set to <code>plain</code>. Please update your permalink settings. StoreEngine works better with search engine friendly permalink.', 'storeengine' ),
795 'button_text' => __( 'Update Permalink', 'storeengine' ),
796 'button_action' => admin_url( 'options-permalink.php' ),
797 ] );
798 }
799
800 /**
801 * Check if uploads directory is protected.
802 *
803 * @return bool
804 */
805 protected static function is_uploads_directory_protected(): bool {
806 $status = get_transient( '_storeengine_upload_directory_status' );
807
808 // Check for cache.
809 if ( false !== $status ) {
810 return 'protected' === $status;
811 }
812
813 // retry creating index & .htaccess files.
814 Installer::create_base_secure_directory();
815
816 // Get only data from the uploads directory.
817 $uploads = wp_get_upload_dir();
818
819 // Check for the "uploads/storeengine_uploads" directory.
820 $response = wp_safe_remote_get( esc_url_raw( $uploads['baseurl'] . '/storeengine_uploads/' ), [ 'redirection' => 0 ] );
821 $response_code = intval( wp_remote_retrieve_response_code( $response ) );
822 $response_content = wp_remote_retrieve_body( $response );
823
824 // Check if returns 200 with empty content in case can open an index.html file,
825 // and check for non-200 codes in case the directory is protected.
826 $is_protected = ( 200 === $response_code && empty( $response_content ) ) || ( 200 !== $response_code );
827
828 set_transient(
829 '_storeengine_upload_directory_status',
830 $is_protected ? 'protected' : 'unprotected',
831 DAY_IN_SECONDS
832 );
833
834 return $is_protected;
835 }
836
837 public function add_unprotected_uploads_directory_notice() {
838 if ( self::has_notice( 'uploads_directory_is_unprotected' ) ) {
839 return;
840 }
841
842 $uploads = wp_get_upload_dir();
843
844 /** @noinspection HtmlUnknownTarget */
845 self::add_notice( 'uploads_directory_is_unprotected', [
846 'type' => 'error',
847 'dismissible' => true,
848 'message' => sprintf(
849 /* translators: 1: uploads directory URL 2: documentation URL */
850 __( 'Your store\'s uploads directory is <a href="%1$s" target="_blank" rel="noopener">browsable via the web</a>. We strongly recommend <a href="%2$s" target="_blank" rel="noopener">configuring your web server to prevent directory indexing</a>.', 'storeengine' ),
851 esc_url( $uploads['baseurl'] . '/storeengine_uploads' ),
852 'https://storeengine.pro/docs/prevent-public-access-to-storeengine-uploads/'
853 ),
854 ] );
855 }
856
857 protected function maybe_show_ablocks_notice(): bool {
858 if (
859 isset( $_REQUEST['action'], $_REQUEST['plugin'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended
860 'install-plugin' === sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended
861 'ablocks' === sanitize_text_field( wp_unslash( $_REQUEST['plugin'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
862 ) {
863 return false;
864 }
865
866 return ! self::has_notice( 'install_ablocks' ) && ! Helper::is_plugin_active( 'ablocks/ablocks.php' ) && ! self::is_user_dismissed( 'install_ablocks' );
867 }
868
869 public function add_ablocks_notice() {
870 if ( ! $this->maybe_show_ablocks_notice() ) {
871 return;
872 }
873
874 if ( ! Helper::is_plugin_installed( 'ablocks/ablocks.php' ) ) {
875 $type = __( 'install & activate', 'storeengine' );
876 $button = __( 'Install & Activate aBlocks', 'storeengine' );
877 $install_url = Helper::get_plugin_install_url( 'ablocks' );
878 } else {
879 $type = __( 'activate', 'storeengine' );
880 $button = __( 'Activate aBlocks', 'storeengine' );
881 $install_url = Helper::get_plugin_activation_url( 'ablocks/ablocks.php' );
882 }
883
884 /** @noinspection HtmlUnknownTarget */
885 self::add_notice( 'install_ablocks', [
886 'type' => 'info',
887 'dismissible' => true,
888 'message' => sprintf(
889 /* translators: %1$s: install or install & activate %2$s: aBlocks WordPress plugin repository URL. */
890 __( '<b>StoreEngine</b> offers 20+ Gutenberg Blocks powered by <a href="%2$s" target="_blank" rel="noopener">aBlocks</a>. Please %1$s <a href="%2$s" target="_blank" rel="noopener">aBlocks</a> to use them.', 'storeengine' ),
891 $type,
892 'https://wordpress.org/plugins/ablocks/'
893 ),
894 'button_text' => $button,
895 'button_action' => $install_url,
896 'button_target' => $this->maybe_inside_dashboard() ? '_blank' : '',
897 ] );
898 }
899
900 public static function show_get_pro_nag() {
901 if ( self::has_notice( 'se-get-pro-nag' ) || defined( 'STOREENGINE_PRO_VERSION' ) ) {
902 return;
903 }
904
905 // Give the store a few days to settle in before nagging about Pro.
906 // `storeengine_first_install_time` is stored via Helper::get_time(), so
907 // comparing against the same clock keeps the 3-day window accurate.
908 $installed_at = (int) get_option( 'storeengine_first_install_time', 0 );
909 if ( ! $installed_at || ( Helper::get_time() - $installed_at ) < ( 3 * DAY_IN_SECONDS ) ) {
910 return;
911 }
912
913 $link = add_query_arg(
914 [
915 'utm_source' => 'storeengine-plugin',
916 'utm_medium' => 'admin-notice',
917 'utm_campaign' => 'upgrade-to-pro',
918 'utm_content' => 'get-pro-notice',
919 'utm_term' => 'free-user',
920 'locale' => get_locale(),
921 ],
922 'https://storeengine.pro/pricing'
923 );
924
925 self::add_notice( 'se-get-pro-nag', [
926 'type' => self::TYPE_INFO,
927 'large' => true,
928 'dismissible' => true,
929 'title' => '',
930 'message' => '<h3>'.__( 'Unlock more with StoreEngine Pro 🚀', 'storeengine' ) .'</h3><p>'.__( 'Upgrade to <strong>StoreEngine Pro</strong> to unlock powerful features and get priority support—so you can build faster, customize more, and grow without limitations.', 'storeengine' ) . '</p>',
931 'button_text' => __( 'Get Pro', 'storeengine' ),
932 'button_action' => $link,
933 'button_target' => '_blank',
934 ] );
935 }
936
937 public static function remove_get_pro_nag() {
938 self::remove_notice( 'se-get-pro-nag' );
939 }
940 }
941