Agentic
4 months ago
BlockTemplates
4 weeks ago
EmailImprovements
1 year ago
EmailPreview
4 weeks ago
Emails
1 year ago
ImportExport
1 year ago
Logging
1 year ago
Marketing
2 years ago
Notes
4 weeks ago
Onboarding
5 months ago
Orders
4 weeks ago
ProductForm
2 years ago
ProductReviews
4 weeks ago
RemoteFreeExtensions
4 weeks ago
Schedulers
4 weeks ago
Settings
2 weeks ago
Suggestions
4 weeks ago
WCPayPromotion
10 months ago
ActivityPanels.php
3 years ago
Analytics.php
4 weeks ago
CategoryLookup.php
4 years ago
Coupons.php
1 year ago
CouponsMovedTrait.php
5 months ago
CustomerEffortScoreTracks.php
7 months ago
Events.php
2 months ago
FeaturePlugin.php
4 months ago
Homescreen.php
1 month ago
Loader.php
4 weeks ago
Marketing.php
1 year ago
Marketplace.php
5 months ago
MobileAppBanner.php
4 years ago
OrderMilestoneEasterEgg.php
4 weeks ago
RemoteInboxNotifications.php
3 years ago
Settings.php
2 weeks ago
ShippingLabelBanner.php
1 year ago
ShippingLabelBannerDisplayRules.php
1 year ago
SiteHealth.php
3 years ago
Survey.php
4 years ago
SystemStatusReport.php
1 year ago
Translations.php
1 year ago
WCAdminAssets.php
2 weeks ago
WCAdminSharedSettings.php
1 year ago
WCAdminUser.php
9 months ago
WcPayWelcomePage.php
1 year ago
OrderMilestoneEasterEgg.php
451 lines
| 1 | <?php |
| 2 | |
| 3 | declare( strict_types = 1 ); |
| 4 | |
| 5 | namespace Automattic\WooCommerce\Internal\Admin; |
| 6 | |
| 7 | use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore; |
| 8 | use Automattic\WooCommerce\Utilities\OrderUtil; |
| 9 | |
| 10 | /** |
| 11 | * Displays a full-screen animated piñata overlay when a merchant opens a |
| 12 | * milestone order (1st, 100th, or 1000th real order) in the admin. |
| 13 | * |
| 14 | * Fires for any real paid order: status is processing or completed and a |
| 15 | * transaction ID is present. |
| 16 | * |
| 17 | * @since 10.9.0 |
| 18 | */ |
| 19 | class OrderMilestoneEasterEgg { |
| 20 | |
| 21 | /** |
| 22 | * Option key used to cache computed milestone order IDs. |
| 23 | */ |
| 24 | private const MILESTONE_CACHE_OPTION = '_wc_order_milestone_egg_order_ids'; |
| 25 | |
| 26 | /** |
| 27 | * Option key used to track whether all milestone order IDs have been found. |
| 28 | */ |
| 29 | private const MILESTONES_COMPLETE_OPTION = '_wc_order_milestone_egg_milestones_complete'; |
| 30 | |
| 31 | /** |
| 32 | * Maximum number of qualifying orders needed to resolve all milestones. |
| 33 | */ |
| 34 | private const MAX_QUALIFYING_ORDERS = 1000; |
| 35 | |
| 36 | /** |
| 37 | * Milestone positions mapped to milestone message keys. |
| 38 | */ |
| 39 | private const MILESTONE_POSITIONS = array( |
| 40 | 0 => 'first', |
| 41 | 99 => 'hundred', |
| 42 | 999 => 'thousand', |
| 43 | ); |
| 44 | |
| 45 | /** |
| 46 | * Sets up the hooks. |
| 47 | * |
| 48 | * @internal |
| 49 | * |
| 50 | * @since 10.9.0 |
| 51 | */ |
| 52 | final public function init(): void { |
| 53 | add_action( 'admin_enqueue_scripts', array( $this, 'handle_admin_enqueue_scripts' ) ); |
| 54 | add_action( 'wp_ajax_wc_egg_dismiss', array( $this, 'handle_ajax_dismiss' ) ); |
| 55 | add_action( 'wp_ajax_wc_egg_opt_out', array( $this, 'handle_ajax_opt_out' ) ); |
| 56 | add_action( 'woocommerce_new_order', array( $this, 'clear_milestone_cache' ), 10, 0 ); |
| 57 | add_action( 'woocommerce_update_order', array( $this, 'clear_milestone_cache' ), 10, 0 ); |
| 58 | add_action( 'woocommerce_delete_order', array( $this, 'clear_milestone_cache' ), 10, 0 ); |
| 59 | add_action( 'woocommerce_trash_order', array( $this, 'clear_milestone_cache' ), 10, 0 ); |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * Clears cached milestone order IDs until all milestones are complete. |
| 64 | * |
| 65 | * Once the 1st, 100th, and 1000th qualifying order IDs have been found, |
| 66 | * later orders cannot create additional milestone overlays, so keep the cache |
| 67 | * stable and avoid recomputing it after routine order changes. |
| 68 | * |
| 69 | * @internal |
| 70 | */ |
| 71 | public function clear_milestone_cache(): void { |
| 72 | if ( wc_string_to_bool( get_option( self::MILESTONES_COMPLETE_OPTION, 'no' ) ) ) { |
| 73 | return; |
| 74 | } |
| 75 | |
| 76 | delete_option( self::MILESTONE_CACHE_OPTION ); |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Opts the current user out of all future milestone overlays. |
| 81 | * |
| 82 | * @internal |
| 83 | */ |
| 84 | public function handle_ajax_opt_out(): void { |
| 85 | check_ajax_referer( 'wc_egg_dismiss', 'nonce' ); |
| 86 | update_user_meta( get_current_user_id(), '_wc_egg_opted_out', '1' ); |
| 87 | wp_die(); |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * Marks a milestone order as dismissed for the current user. |
| 92 | * |
| 93 | * @internal |
| 94 | */ |
| 95 | public function handle_ajax_dismiss(): void { |
| 96 | check_ajax_referer( 'wc_egg_dismiss', 'nonce' ); |
| 97 | $order_id = isset( $_POST['order_id'] ) ? absint( wp_unslash( $_POST['order_id'] ) ) : 0; |
| 98 | if ( $order_id > 0 ) { |
| 99 | update_user_meta( get_current_user_id(), '_wc_egg_seen_' . $order_id, '1' ); |
| 100 | } |
| 101 | wp_die(); |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Enqueues the milestone overlay script when the current order is a qualifying milestone. |
| 106 | * |
| 107 | * @internal |
| 108 | */ |
| 109 | public function handle_admin_enqueue_scripts(): void { |
| 110 | /** |
| 111 | * Filters whether the order milestone easter egg feature is enabled. |
| 112 | * |
| 113 | * Return false to disable the feature entirely — no order queries or assets will be loaded. |
| 114 | * |
| 115 | * @param bool $enabled Whether the feature is enabled. Default true. |
| 116 | * |
| 117 | * @since 10.9.0 |
| 118 | */ |
| 119 | if ( ! apply_filters( 'wc_order_milestone_egg_enabled', true ) ) { |
| 120 | return; |
| 121 | } |
| 122 | |
| 123 | if ( ! function_exists( 'wc_get_order' ) ) { |
| 124 | return; |
| 125 | } |
| 126 | |
| 127 | // phpcs:disable WordPress.Security.NonceVerification.Recommended |
| 128 | $woo_egg_key = isset( $_GET['woo_egg'] ) ? sanitize_text_field( wp_unslash( $_GET['woo_egg'] ) ) : ''; |
| 129 | $page_param = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; |
| 130 | $action_param = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : ''; |
| 131 | $id_param = isset( $_GET['id'] ) ? absint( wp_unslash( $_GET['id'] ) ) : 0; |
| 132 | // phpcs:enable WordPress.Security.NonceVerification.Recommended |
| 133 | |
| 134 | // Preview: ?woo_egg=first|hundred|thousand lets admins preview any milestone without real orders. |
| 135 | // Only available when WP_DEBUG is enabled to prevent accidental triggering in production. |
| 136 | $is_debug_preview = ( defined( 'WP_DEBUG' ) && WP_DEBUG ) && current_user_can( 'manage_options' ) && '' !== $woo_egg_key; |
| 137 | |
| 138 | // Respect the user's opt-out preference (debug preview always shows). |
| 139 | if ( ! $is_debug_preview && get_user_meta( get_current_user_id(), '_wc_egg_opted_out', true ) ) { |
| 140 | return; |
| 141 | } |
| 142 | |
| 143 | // Only run milestone logic on the HPOS order edit page to avoid overhead on every admin page. |
| 144 | $is_order_edit_page = 'wc-orders' === $page_param && 'edit' === $action_param; |
| 145 | |
| 146 | if ( ! $is_debug_preview && ! $is_order_edit_page ) { |
| 147 | return; |
| 148 | } |
| 149 | |
| 150 | // For real order pages: check cheaply whether the current order qualifies |
| 151 | // before running the milestone lookup. The lookup relies on HPOS columns. |
| 152 | if ( ! $is_debug_preview ) { |
| 153 | if ( |
| 154 | ! OrderUtil::custom_orders_table_usage_is_enabled() |
| 155 | || $id_param <= 0 |
| 156 | || ! $this->is_qualifying_order( $id_param ) |
| 157 | ) { |
| 158 | return; |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | $milestone_map = $is_debug_preview ? array() : $this->get_milestone_map(); |
| 163 | |
| 164 | if ( ! $is_debug_preview && empty( $milestone_map ) ) { |
| 165 | return; |
| 166 | } |
| 167 | |
| 168 | // Remove milestones the current user has already seen. |
| 169 | if ( ! $is_debug_preview ) { |
| 170 | $user_id = get_current_user_id(); |
| 171 | foreach ( array_keys( $milestone_map ) as $order_id ) { |
| 172 | if ( get_user_meta( $user_id, '_wc_egg_seen_' . $order_id, true ) ) { |
| 173 | unset( $milestone_map[ $order_id ] ); |
| 174 | } |
| 175 | } |
| 176 | if ( empty( $milestone_map ) ) { |
| 177 | return; |
| 178 | } |
| 179 | |
| 180 | // Only show the overlay when the current order is itself the milestone. |
| 181 | if ( ! isset( $milestone_map[ $id_param ] ) ) { |
| 182 | return; |
| 183 | } |
| 184 | $milestone_map = array( $id_param => $milestone_map[ $id_param ] ); |
| 185 | } |
| 186 | |
| 187 | // Only load the SVG variants needed for the matched milestones. |
| 188 | $all_msgs = array(); |
| 189 | if ( $is_debug_preview ) { |
| 190 | $all_msgs = $this->get_milestone_messages(); |
| 191 | $preview_variant = $all_msgs[ $woo_egg_key ]['variant'] ?? null; |
| 192 | $needed_variants = $preview_variant ? array( $preview_variant ) : array_keys( $this->get_variant_map() ); |
| 193 | } else { |
| 194 | $needed_variants = array_unique( |
| 195 | array_filter( array_column( array_values( $milestone_map ), 'variant' ) ) |
| 196 | ); |
| 197 | } |
| 198 | |
| 199 | $svg_data = $this->get_svg_data( $needed_variants ); |
| 200 | $labels = $this->get_ui_labels(); |
| 201 | |
| 202 | WCAdminAssets::register_script( 'wp-admin-scripts', 'order-milestone-easter-egg', true ); |
| 203 | |
| 204 | $localize_data = array( |
| 205 | 'milestones' => $milestone_map, |
| 206 | 'svgData' => $svg_data, |
| 207 | 'labels' => $labels, |
| 208 | 'dismiss' => array( |
| 209 | 'url' => admin_url( 'admin-ajax.php' ), |
| 210 | 'nonce' => wp_create_nonce( 'wc_egg_dismiss' ), |
| 211 | ), |
| 212 | ); |
| 213 | |
| 214 | if ( $is_debug_preview ) { |
| 215 | $localize_data['allMilestones'] = $all_msgs; |
| 216 | } |
| 217 | |
| 218 | wp_localize_script( 'wc-admin-order-milestone-easter-egg', 'wcOrderMilestoneEgg', $localize_data ); |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * Returns true if the given order qualifies for milestone consideration: status is |
| 223 | * processing or completed, and a transaction ID is present. |
| 224 | * |
| 225 | * Used as a cheap pre-filter before running the full milestone count query. |
| 226 | * |
| 227 | * @param int $order_id The order ID to check. |
| 228 | * @return bool |
| 229 | */ |
| 230 | public function is_qualifying_order( int $order_id ): bool { |
| 231 | $order = wc_get_order( $order_id ); |
| 232 | if ( ! $order instanceof \WC_Order ) { |
| 233 | return false; |
| 234 | } |
| 235 | return '' !== $order->get_transaction_id() |
| 236 | && in_array( $order->get_status(), array( 'processing', 'completed' ), true ); |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Returns a map of milestone order IDs to their milestone data. |
| 241 | * |
| 242 | * Uses cached milestone order IDs when available; otherwise computes and caches |
| 243 | * them by scanning qualifying orders in chronological order. |
| 244 | * |
| 245 | * @return array<int, array<string, string>> |
| 246 | */ |
| 247 | private function get_milestone_map(): array { |
| 248 | $milestone_order_ids = $this->get_cached_milestone_order_ids(); |
| 249 | if ( null === $milestone_order_ids ) { |
| 250 | $milestone_order_ids = $this->compute_milestone_order_ids(); |
| 251 | update_option( self::MILESTONE_CACHE_OPTION, $milestone_order_ids, false ); |
| 252 | $this->update_milestones_complete_option( $milestone_order_ids ); |
| 253 | } |
| 254 | |
| 255 | $messages = $this->get_milestone_messages(); |
| 256 | $milestone_map = array(); |
| 257 | |
| 258 | foreach ( $milestone_order_ids as $key => $order_id ) { |
| 259 | if ( isset( $messages[ $key ] ) ) { |
| 260 | $milestone_map[ $order_id ] = $messages[ $key ]; |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * Filters the map of milestone order IDs to their milestone data. |
| 266 | * |
| 267 | * @param array<int, array<string, string>> $milestone_map Map of order ID to milestone data. |
| 268 | * |
| 269 | * @since 10.9.0 |
| 270 | */ |
| 271 | return apply_filters( 'wc_order_milestone_egg_map', $milestone_map ); |
| 272 | } |
| 273 | |
| 274 | /** |
| 275 | * Returns cached milestone order IDs, or null when the cache is missing. |
| 276 | * |
| 277 | * @return array<string, int>|null |
| 278 | */ |
| 279 | private function get_cached_milestone_order_ids(): ?array { |
| 280 | $cached = get_option( self::MILESTONE_CACHE_OPTION, null ); |
| 281 | if ( ! is_array( $cached ) ) { |
| 282 | return null; |
| 283 | } |
| 284 | |
| 285 | $milestone_order_ids = array(); |
| 286 | foreach ( self::MILESTONE_POSITIONS as $key ) { |
| 287 | if ( isset( $cached[ $key ] ) ) { |
| 288 | $order_id = absint( $cached[ $key ] ); |
| 289 | if ( $order_id > 0 ) { |
| 290 | $milestone_order_ids[ $key ] = $order_id; |
| 291 | } |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | return $milestone_order_ids; |
| 296 | } |
| 297 | |
| 298 | /** |
| 299 | * Updates the complete option when all milestone IDs have been found. |
| 300 | * |
| 301 | * @param array<string, int> $milestone_order_ids Milestone order IDs keyed by milestone name. |
| 302 | */ |
| 303 | private function update_milestones_complete_option( array $milestone_order_ids ): void { |
| 304 | if ( count( $milestone_order_ids ) === count( self::MILESTONE_POSITIONS ) ) { |
| 305 | update_option( self::MILESTONES_COMPLETE_OPTION, 'yes', false ); |
| 306 | return; |
| 307 | } |
| 308 | |
| 309 | delete_option( self::MILESTONES_COMPLETE_OPTION ); |
| 310 | } |
| 311 | |
| 312 | /** |
| 313 | * Computes milestone order IDs from HPOS without hydrating order objects. |
| 314 | * |
| 315 | * @return array<string, int> |
| 316 | */ |
| 317 | private function compute_milestone_order_ids(): array { |
| 318 | global $wpdb; |
| 319 | |
| 320 | if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) { |
| 321 | return array(); |
| 322 | } |
| 323 | |
| 324 | $qualifying_order_ids = array_map( |
| 325 | 'absint', |
| 326 | $wpdb->get_col( |
| 327 | $wpdb->prepare( |
| 328 | 'SELECT id |
| 329 | FROM %i |
| 330 | WHERE type = %s |
| 331 | AND status IN ( %s, %s ) |
| 332 | AND transaction_id IS NOT NULL |
| 333 | AND transaction_id <> %s |
| 334 | ORDER BY date_created_gmt ASC, id ASC |
| 335 | LIMIT %d', |
| 336 | OrdersTableDataStore::get_orders_table_name(), |
| 337 | 'shop_order', |
| 338 | 'wc-processing', |
| 339 | 'wc-completed', |
| 340 | '', |
| 341 | self::MAX_QUALIFYING_ORDERS |
| 342 | ) |
| 343 | ) |
| 344 | ); |
| 345 | |
| 346 | $milestone_order_ids = array(); |
| 347 | foreach ( self::MILESTONE_POSITIONS as $pos => $key ) { |
| 348 | if ( isset( $qualifying_order_ids[ $pos ] ) ) { |
| 349 | $milestone_order_ids[ $key ] = (int) $qualifying_order_ids[ $pos ]; |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | return $milestone_order_ids; |
| 354 | } |
| 355 | |
| 356 | /** |
| 357 | * Returns milestone copy and variant configuration keyed by milestone name. |
| 358 | * |
| 359 | * @return array<string, array<string, string>> |
| 360 | */ |
| 361 | private function get_milestone_messages(): array { |
| 362 | return array( |
| 363 | 'first' => array( |
| 364 | 'title' => __( 'Cha-ching! Order number one', 'woocommerce' ), |
| 365 | 'subtitle' => __( "That's a big deal. Smash the llama. You've earned it.", 'woocommerce' ), |
| 366 | 'variant' => 'llama', |
| 367 | 'boomText' => __( 'One down', 'woocommerce' ), |
| 368 | ), |
| 369 | 'hundred' => array( |
| 370 | 'title' => __( 'Triple digits looks good on you', 'woocommerce' ), |
| 371 | 'subtitle' => __( "A hundred orders means you're juggling a lot. Take a moment to celebrate", 'woocommerce' ), |
| 372 | 'variant' => 'octo', |
| 373 | 'boomText' => __( 'Hands full', 'woocommerce' ), |
| 374 | ), |
| 375 | 'thousand' => array( |
| 376 | 'title' => __( 'ONE. THOUSAND. ORDERS', 'woocommerce' ), |
| 377 | 'subtitle' => __( 'Seriously. A thousand orders. This called for a bigger piñata', 'woocommerce' ), |
| 378 | 'variant' => 'whale', |
| 379 | 'boomText' => __( 'Off the charts', 'woocommerce' ), |
| 380 | ), |
| 381 | ); |
| 382 | } |
| 383 | |
| 384 | /** |
| 385 | * Returns translated UI labels for the overlay script. |
| 386 | * |
| 387 | * @return array<string, string> |
| 388 | */ |
| 389 | private function get_ui_labels(): array { |
| 390 | return array( |
| 391 | 'cta' => __( "Let's go!", 'woocommerce' ), |
| 392 | 'closeLabel' => __( 'Close', 'woocommerce' ), |
| 393 | 'closeTitle' => __( 'Close (Esc)', 'woocommerce' ), |
| 394 | 'optOut' => __( "Don't show again", 'woocommerce' ), |
| 395 | ); |
| 396 | } |
| 397 | |
| 398 | /** |
| 399 | * Returns the map of variant keys to their SVG filenames. |
| 400 | * |
| 401 | * @return array<string, string> |
| 402 | */ |
| 403 | private function get_variant_map(): array { |
| 404 | return array( |
| 405 | 'llama' => 'woo-pinata-llama2.svg', |
| 406 | 'octo' => 'woo-octo.svg', |
| 407 | 'whale' => 'woo-whale.svg', |
| 408 | ); |
| 409 | } |
| 410 | |
| 411 | /** |
| 412 | * Loads and returns SVG assets as inline strings. |
| 413 | * |
| 414 | * Only the variant SVGs listed in $variants are loaded; shared assets |
| 415 | * (confetti, stick, sprinkle) are always included. |
| 416 | * |
| 417 | * @param string[] $variants Variant keys to load (e.g. ['llama', 'octo']). |
| 418 | * @return array<string, string> |
| 419 | */ |
| 420 | private function get_svg_data( array $variants = array() ): array { |
| 421 | $svg_dir = WC_ABSPATH . 'assets/images/pinata/'; |
| 422 | |
| 423 | if ( empty( $variants ) ) { |
| 424 | $variants = array_keys( $this->get_variant_map() ); |
| 425 | } |
| 426 | |
| 427 | $svg_data = array(); |
| 428 | $variant_map = $this->get_variant_map(); |
| 429 | |
| 430 | // phpcs:disable WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 431 | foreach ( $variants as $key ) { |
| 432 | if ( isset( $variant_map[ $key ] ) ) { |
| 433 | $svg_data[ $key ] = (string) file_get_contents( $svg_dir . $variant_map[ $key ] ); |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | $svg_data['confetti'] = (string) file_get_contents( $svg_dir . 'confetti.svg' ); |
| 438 | $svg_data['stick'] = (string) file_get_contents( $svg_dir . 'stick.svg' ); |
| 439 | $sprinkle_svg = (string) file_get_contents( $svg_dir . 'sprinkle.svg' ); |
| 440 | // phpcs:enable WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents |
| 441 | |
| 442 | $sprinkle_svg = preg_replace( '/<defs>.*?<\/defs>/s', '', $sprinkle_svg ) ?? ''; |
| 443 | $sprinkle_svg = preg_replace( '/\s*clip-path="[^"]*"/', '', $sprinkle_svg ) ?? ''; |
| 444 | $sprinkle_svg = preg_replace( '/<rect[^>]+fill="white"[^>]*\/?>/', '', $sprinkle_svg ) ?? ''; |
| 445 | |
| 446 | $svg_data['sprinkle'] = $sprinkle_svg; |
| 447 | |
| 448 | return $svg_data; |
| 449 | } |
| 450 | } |
| 451 |