PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.4
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.4
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / my-wordpress / integrations / woocommerce.php

woocommerce.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.4, at includes/my-wordpress/integrations/woocommerce.php

2,045 lines 64.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — My WordPress: WooCommerce integration.
4 *
5 * Everything in this file is inert unless WooCommerce is active. It
6 * makes the shop folder behave like a shop rather than like a pile of
7 * post types:
8 *
9 * - The folder is labelled **Woo** and carries WooCommerce's own
10 * mark. ("WooCommerce" wraps onto two lines under a 88px tile.)
11 * - **Orders** are served through `wc_get_orders()` instead of a
12 * `WP_Query`. WooCommerce's High-Performance Order Storage moves
13 * orders out of `wp_posts` into its own tables, so the generic
14 * post-type path returns an empty folder on any modern store.
15 * Going through WooCommerce's own API covers both storages.
16 * - The right pane gets merchant facts — a product's price, stock
17 * and units sold; an order's total, customer and line items; a
18 * coupon's validity and usage — and the folder itself gets store
19 * totals.
20 *
21 * REST surface (all read-only, all capability-gated):
22 *
23 * GET desktop-mode/v1/woocommerce/orders
24 * GET desktop-mode/v1/woocommerce/orders/<id>
25 * GET desktop-mode/v1/woocommerce/summary/<type>/<id>
26 * GET desktop-mode/v1/woocommerce/store
27 *
28 * @package OpenStation
29 */
30
31 defined( 'ABSPATH' ) || exit;
32
33 /**
34 * Whether WooCommerce is active and its API is loaded.
35 *
36 * @return bool
37 */
38 function openstation_my_wordpress_woo_active() {
39 return class_exists( 'WooCommerce' ) && function_exists( 'wc_get_orders' );
40 }
41
42 /**
43 * WooCommerce's mark as a `currentColor` SVG data URI.
44 *
45 * WooCommerce builds the same glyph inline in `WC_Admin_Menus::admin_menu()`
46 * with a hard-coded grey fill, and it's a local variable there — not
47 * reachable, and not tintable. Re-emitting it with `currentColor` lets
48 * `renderIcon()` mask it, so the folder icon follows the desktop theme
49 * the way every other icon does.
50 *
51 * @return string Data URI.
52 */
53 function openstation_my_wordpress_woo_icon() {
54 $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 85.9 47.6">'
55 . '<path fill="currentColor" d="M77.4,0.1c-4.3,0-7.1,1.4-9.6,6.1L56.4,27.7V8.6c0-5.7-2.7-8.5-7.7-8.5'
56 . 's-7.1,1.7-9.6,6.5L28.3,27.7V8.8c0-6.1-2.5-8.7-8.6-8.7H7.3C2.6,0.1,0,2.3,0,6.3s2.5,6.4,7.1,6.4h5.1v24.1'
57 . 'c0,6.8,4.6,10.8,11.2,10.8S33,45,36.3,38.9l7.2-13.5v11.4c0,6.7,4.4,10.8,11.1,10.8s9.2-2.3,13-8.7l16.6-28'
58 . 'c3.6-6.1,1.1-10.8-6.9-10.8C77.3,0.1,77.3,0.1,77.4,0.1z"/></svg>';
59
60 return 'data:image/svg+xml;base64,' . base64_encode( $svg );
61 }
62
63 /**
64 * Label and icon for the WooCommerce folder.
65 *
66 * @param array|null $group Resolved group.
67 * @param string $post_type Post type slug.
68 * @return array|null
69 */
70 function openstation_my_wordpress_woo_group( $group, $post_type ) {
71 unset( $post_type );
72 if ( ! is_array( $group ) || 'plugin:woocommerce' !== ( $group['id'] ?? '' ) ) {
73 return $group;
74 }
75
76 // "WooCommerce" wraps to two lines in an 88px tile and reads badly.
77 $group['label'] = _x( 'Woo', 'WooCommerce folder name', 'desktop-mode' );
78 $group['icon'] = openstation_my_wordpress_woo_icon();
79 // Ahead of other plugin folders — for a shop this is the folder
80 // the merchant opens all day.
81 $group['order'] = 15;
82
83 return $group;
84 }
85
86 /**
87 * Replace the generic Orders section with one backed by
88 * `wc_get_orders()`.
89 *
90 * Registered at priority 5 — ahead of the generic post-type pass,
91 * which skips any type an existing section already declares via
92 * `post_type`. Same trick a plugin would use to hand-roll a section.
93 *
94 * @param array[] $entities Entity descriptors.
95 * @return array[]
96 */
97 function openstation_my_wordpress_woo_entities( $entities ) {
98 if ( ! is_array( $entities ) || ! openstation_my_wordpress_woo_active() ) {
99 return $entities;
100 }
101
102 $orders = get_post_type_object( 'shop_order' );
103 if ( ! $orders instanceof WP_Post_Type ) {
104 return $entities;
105 }
106 if ( empty( $orders->cap->edit_posts ) || ! current_user_can( $orders->cap->edit_posts ) ) {
107 return $entities;
108 }
109
110 $group = openstation_my_wordpress_woo_group(
111 array(
112 'id' => 'plugin:woocommerce',
113 'label' => 'WooCommerce',
114 'icon' => 'dashicons-admin-plugins',
115 'order' => 20,
116 ),
117 'shop_order'
118 );
119
120 $entities[] = array(
121 'id' => 'wc-orders',
122 'label' => __( 'Orders', 'desktop-mode' ),
123 'icon' => 'dashicons-cart',
124 'restPath' => 'desktop-mode/v1/woocommerce/orders',
125 'kind' => 'post',
126 // Claims `shop_order` so the generic post-type pass skips it,
127 // and drives the `os.shop_order.changed` broadcast.
128 'post_type' => 'shop_order',
129 'thumbnails' => false,
130 // Keeps `wcStatus` from being filtered out of the list rows.
131 'listFields' => array( 'wcStatus' ),
132 'group' => $group['id'],
133 'groupLabel' => $group['label'],
134 'groupIcon' => $group['icon'],
135 'groupOrder' => $group['order'],
136 );
137
138 return $entities;
139 }
140
141 /**
142 * Give WooCommerce's post types icons that mean something.
143 *
144 * These types are submenu entries under the WooCommerce menu, so they
145 * carry no `menu_icon` and fall back to the generic post pin — a pin
146 * for a coupon reads as a mistake.
147 *
148 * @param array $entity Entity descriptor.
149 * @param WP_Post_Type $post_type Post type object.
150 * @return array
151 */
152 function openstation_my_wordpress_woo_entity_icon( $entity, $post_type ) {
153 $icons = array(
154 'product' => 'dashicons-products',
155 'shop_coupon' => 'dashicons-tickets-alt',
156 'shop_order' => 'dashicons-cart',
157 );
158
159 /**
160 * Filter the section icons used for WooCommerce post types.
161 *
162 * **Status: Experimental**
163 *
164 * @param array $icons Post type slug => dashicon class.
165 */
166 $icons = (array) apply_filters( 'openstation_my_wordpress_woo_section_icons', $icons );
167
168 $name = isset( $post_type->name ) ? (string) $post_type->name : '';
169 if ( isset( $icons[ $name ] ) ) {
170 $entity['icon'] = (string) $icons[ $name ];
171 }
172
173 // Coupons carry no featured image; a thumbnail column would just
174 // be a grid of fallback icons.
175 if ( 'shop_coupon' === $name ) {
176 $entity['thumbnails'] = false;
177 $entity['listFields'] = array( 'openstation_woo' );
178 $entity['listQuery'] = array( OPENSTATION_WOO_BANDED_PARAM => '1' );
179 }
180
181 // Products band by stock and category, both of which ride the
182 // `openstation_woo` REST field — declared here so `_fields`
183 // doesn't strip it out of the list rows.
184 if ( 'product' === $name ) {
185 $entity['listFields'] = array( 'openstation_woo' );
186 $entity['listQuery'] = array( OPENSTATION_WOO_BANDED_PARAM => '1' );
187 // A catalogue is looked at, not read — the product photo is
188 // the thing being scanned, and it earns the bigger tile.
189 $entity['tileSize'] = 'large';
190 }
191
192 return $entity;
193 }
194
195 /**
196 * Status bands for the Orders section, ordered so the ones a merchant
197 * has to act on come first.
198 *
199 * Anything WooCommerce (or a plugin) registers that isn't listed here
200 * lands in the trailing "Other" band rather than being dropped.
201 *
202 * @return array[] Each entry: `id`, `label`, `order`, `statuses`.
203 */
204 function openstation_my_wordpress_woo_order_bands() {
205 $labels = wc_get_order_statuses();
206
207 $label_for = static function ( $status ) use ( $labels ) {
208 return $labels[ 'wc-' . $status ] ?? ucfirst( str_replace( '-', ' ', $status ) );
209 };
210
211 $bands = array(
212 array(
213 'id' => 'needs-action',
214 'label' => __( 'Needs attention', 'desktop-mode' ),
215 'order' => 10,
216 'tone' => 'warn',
217 'statuses' => array( 'processing', 'on-hold', 'pending' ),
218 ),
219 array(
220 'id' => 'problem',
221 'label' => __( 'Problems', 'desktop-mode' ),
222 'order' => 20,
223 'tone' => 'danger',
224 'statuses' => array( 'failed' ),
225 ),
226 array(
227 'id' => 'completed',
228 'label' => $label_for( 'completed' ),
229 'order' => 30,
230 'statuses' => array( 'completed' ),
231 ),
232 array(
233 'id' => 'closed',
234 'label' => __( 'Cancelled & refunded', 'desktop-mode' ),
235 'order' => 40,
236 'statuses' => array( 'cancelled', 'refunded' ),
237 ),
238 array(
239 'id' => 'other',
240 'label' => __( 'Other', 'desktop-mode' ),
241 'order' => 50,
242 'statuses' => array(),
243 ),
244 );
245
246 /**
247 * Filter the status bands the Orders section groups tiles into.
248 *
249 * Each entry declares `id`, `label`, `order` (lower renders
250 * first), and `statuses` — WooCommerce status slugs *without* the
251 * `wc-` prefix. The last band in the list catches every status no
252 * other band claims, so keep a catch-all at the end.
253 *
254 * **Status: Experimental**
255 *
256 * @param array[] $bands Default bands.
257 */
258 return (array) apply_filters( 'openstation_my_wordpress_woo_order_bands', $bands );
259 }
260
261 /**
262 * Bands for the Products section: anything out of stock first, then a
263 * band per product category.
264 *
265 * Two groupings at once rather than a mode switch — a merchant scanning
266 * the catalogue wants the empty shelves surfaced regardless of which
267 * category they sit in, and everything else filed where they'd look
268 * for it.
269 *
270 * @return array[] Each entry: `id`, `label`, `order`, and either
271 * `stock` (a status slug) or `category` (a term slug).
272 */
273 function openstation_my_wordpress_woo_product_bands() {
274 return openstation_my_wordpress_woo_count_product_bands(
275 openstation_my_wordpress_woo_product_band_defs()
276 );
277 }
278
279 /**
280 * The product band definitions, without counts.
281 *
282 * Split from the counted version because the per-product band
283 * resolver needs the definitions and the counter needs the resolver —
284 * calling one function for both would recurse.
285 *
286 * @return array[]
287 */
288 function openstation_my_wordpress_woo_product_band_defs() {
289 // Category bands only exist when the catalogue is small enough to
290 // be ordered by band server-side. Offering them without that
291 // ordering is worse than not offering them: rows arrive in date
292 // order, so a category band materialises above whatever the user
293 // is reading every time a stray row for it turns up. A capped
294 // store gets stock bands only, which the meta-key fallback orders
295 // correctly.
296 $with_categories = ! openstation_my_wordpress_woo_catalogue_is_capped();
297
298 $bands = array(
299 array(
300 'id' => 'stock:outofstock',
301 'label' => __( 'Out of stock', 'desktop-mode' ),
302 'order' => 10,
303 'tone' => 'danger',
304 'stock' => 'outofstock',
305 ),
306 array(
307 'id' => 'stock:onbackorder',
308 'label' => __( 'On backorder', 'desktop-mode' ),
309 'order' => 20,
310 'tone' => 'warn',
311 'stock' => 'onbackorder',
312 ),
313 );
314
315 if ( $with_categories ) {
316 $terms = get_terms(
317 array(
318 'taxonomy' => 'product_cat',
319 'hide_empty' => true,
320 'orderby' => 'name',
321 'order' => 'ASC',
322 )
323 );
324
325 $order = 100;
326 foreach ( ( is_wp_error( $terms ) ? array() : (array) $terms ) as $term ) {
327 $bands[] = array(
328 'id' => 'cat:' . $term->slug,
329 'label' => $term->name,
330 'order' => $order,
331 'category' => $term->slug,
332 );
333 $order += 10;
334 }
335 }
336
337 // Catch-all: everything no earlier band claimed — uncategorised
338 // products, or simply "in stock" on a capped catalogue.
339 $bands[] = array(
340 'id' => 'cat:__none',
341 'label' => $with_categories
342 ? __( 'Uncategorised', 'desktop-mode' )
343 : __( 'In stock', 'desktop-mode' ),
344 'order' => PHP_INT_MAX,
345 );
346
347 /**
348 * Filter the bands the Products section groups tiles into.
349 *
350 * Each entry declares `id`, `label`, `order` (lower renders
351 * first), and one matcher: `stock` (a WooCommerce stock-status
352 * slug) or `category` (a `product_cat` slug). Keep a matcher-less
353 * catch-all last — it collects everything no other band claims.
354 *
355 * **Status: Experimental**
356 *
357 * @param array[] $bands Default bands.
358 */
359 return (array) apply_filters( 'openstation_my_wordpress_woo_product_bands', $bands );
360 }
361
362 /**
363 * Attach a row count to every product band.
364 *
365 * The bundle lays out every band that has rows *before* the first page
366 * lands, so bands never appear or reshuffle while the user scrolls —
367 * they only fill. That needs counts up front, which is one cheap
368 * count query per band, cached because this runs on every admin page
369 * load.
370 *
371 * Counts are the number of products a band *would* claim on its own.
372 * A product that is out of stock is claimed by the stock band and
373 * skipped by its category band client-side, so a category count can
374 * read high by however many of its products are out of stock — an
375 * over-estimate that only ever leaves a band laid out and empty, never
376 * a band appearing late.
377 *
378 * @param array[] $bands Band descriptors.
379 * @return array[] Bands with a `count` key.
380 */
381 function openstation_my_wordpress_woo_count_product_bands( $bands ) {
382 $plan = openstation_my_wordpress_woo_product_plan();
383 foreach ( $bands as $i => $band ) {
384 $bands[ $i ]['count'] = (int) ( $plan['counts'][ $band['id'] ] ?? 0 );
385 }
386 return $bands;
387 }
388
389 /**
390 * Largest catalogue this integration will band-order.
391 *
392 * Ordering works by handing WP_Query the full list of product ids in
393 * band order, which becomes a `post__in` clause. That's cheap for a
394 * normal catalogue and silly for a huge one, so past this size the
395 * ordering falls back to stock-status only and the category bands fill
396 * progressively.
397 */
398 const OPENSTATION_WOO_MAX_ORDERED_PRODUCTS = 20000;
399
400 /**
401 * How many products the catalogue holds. Memoized and cached — both
402 * the band definitions and the ordering plan need it, and it must not
403 * recurse into either.
404 *
405 * @return int
406 */
407 function openstation_my_wordpress_woo_product_total() {
408 static $total = null;
409 if ( null !== $total ) {
410 return $total;
411 }
412
413 $cached = get_transient( 'desktop_mode_woo_product_total' );
414 if ( false !== $cached ) {
415 $total = (int) $cached;
416 return $total;
417 }
418
419 $result = wc_get_products(
420 array(
421 'limit' => 1,
422 'paginate' => true,
423 'return' => 'ids',
424 'status' => array( 'publish', 'private', 'draft', 'pending', 'future' ),
425 )
426 );
427 $total = isset( $result->total ) ? (int) $result->total : 0;
428 set_transient( 'desktop_mode_woo_product_total', $total, 5 * MINUTE_IN_SECONDS );
429
430 return $total;
431 }
432
433 /**
434 * Whether the catalogue is too large to band-order server-side.
435 *
436 * @return bool
437 */
438 function openstation_my_wordpress_woo_catalogue_is_capped() {
439 return openstation_my_wordpress_woo_product_total() > OPENSTATION_WOO_MAX_ORDERED_PRODUCTS;
440 }
441
442 /**
443 * The band-ordered product id list, plus an exact row count per band.
444 *
445 * Bands only stop reshuffling if rows *arrive* in band order — laying
446 * the bands out ahead of time isn't enough, because a band that fills
447 * late still expands above whatever the user is reading. WordPress
448 * can't express "order by stock, then by category" in one query, so
449 * the order is computed once here (one id query per band, deduped so
450 * a product in two categories lands in the first that claims it) and
451 * replayed as `post__in` on every page request.
452 *
453 * Cached, because it runs on every admin page load; flushed whenever a
454 * product changes.
455 *
456 * @return array{ids: int[], counts: array<string,int>, capped: bool}
457 */
458 function openstation_my_wordpress_woo_product_plan() {
459 static $memo = null;
460 if ( null !== $memo ) {
461 return $memo;
462 }
463
464 $cache_key = 'desktop_mode_woo_product_plan';
465 $cached = get_transient( $cache_key );
466 if ( is_array( $cached ) && isset( $cached['ids'], $cached['counts'] ) ) {
467 $memo = $cached;
468 return $memo;
469 }
470
471 $statuses = array( 'publish', 'private', 'draft', 'pending', 'future' );
472 $total = openstation_my_wordpress_woo_product_total();
473
474 if ( openstation_my_wordpress_woo_catalogue_is_capped() ) {
475 $memo = array(
476 'ids' => array(),
477 'counts' => array(),
478 'capped' => true,
479 'products' => $total,
480 );
481 set_transient( $cache_key, $memo, 5 * MINUTE_IN_SECONDS );
482 return $memo;
483 }
484
485 $ordered = array();
486 $counts = array();
487 $seen = array();
488
489 foreach ( openstation_my_wordpress_woo_product_band_defs() as $band ) {
490 $args = array(
491 'limit' => -1,
492 'return' => 'ids',
493 'orderby' => 'date',
494 'order' => 'DESC',
495 'status' => $statuses,
496 );
497 if ( ! empty( $band['stock'] ) ) {
498 $args['stock_status'] = $band['stock'];
499 } elseif ( ! empty( $band['category'] ) ) {
500 $args['category'] = array( $band['category'] );
501 } else {
502 // Catch-all: whatever no earlier band claimed. Resolved
503 // below from the remainder rather than by query — there's
504 // no cheap way to ask for "has no product_cat term".
505 $args = null;
506 }
507
508 $ids = null === $args ? array() : (array) wc_get_products( $args );
509
510 $claimed = 0;
511 foreach ( $ids as $id ) {
512 $id = (int) $id;
513 if ( isset( $seen[ $id ] ) ) {
514 continue;
515 }
516 $seen[ $id ] = true;
517 $ordered[] = $id;
518 ++$claimed;
519 }
520 $counts[ $band['id'] ] = $claimed;
521 }
522
523 // Anything no band claimed — uncategorised, in stock — trails the
524 // list under the catch-all band.
525 $remainder = wc_get_products(
526 array(
527 'limit' => -1,
528 'return' => 'ids',
529 'orderby' => 'date',
530 'order' => 'DESC',
531 'status' => $statuses,
532 )
533 );
534 $trailing = 0;
535 foreach ( (array) $remainder as $id ) {
536 $id = (int) $id;
537 if ( isset( $seen[ $id ] ) ) {
538 continue;
539 }
540 $seen[ $id ] = true;
541 $ordered[] = $id;
542 ++$trailing;
543 }
544 $counts['cat:__none'] = ( $counts['cat:__none'] ?? 0 ) + $trailing;
545
546 $memo = array(
547 'ids' => $ordered,
548 'counts' => $counts,
549 'capped' => false,
550 'products' => $total,
551 );
552 set_transient( $cache_key, $memo, 5 * MINUTE_IN_SECONDS );
553
554 return $memo;
555 }
556
557 /**
558 * Query parameter the site window's list requests carry so the band
559 * ordering filters can scope themselves. Declared on the section
560 * descriptor as `listQuery`, sent by `fetchEntityList()`.
561 *
562 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
563 * persisted or externally-visible identifier, so renaming it would
564 * orphan data already written by live installs (or break a live
565 * URL). The mismatch between this constant's name and its value is
566 * deliberate — it is NOT a half-finished rename.
567 */
568 const OPENSTATION_WOO_BANDED_PARAM = 'desktop_mode_bands';
569
570 /**
571 * Whether a REST request asked for band ordering.
572 *
573 * `rest_product_query` / `rest_shop_coupon_query` fire for every caller
574 * of those collections, not just us — the Product Collection block
575 * renders through the same filter. Without this check a storefront's
576 * chosen sort order would be silently replaced by ours.
577 *
578 * @param WP_REST_Request $request Request.
579 * @return bool
580 */
581 function openstation_my_wordpress_woo_is_banded_request( $request ) {
582 if ( ! $request instanceof WP_REST_Request ) {
583 return false;
584 }
585 return '1' === (string) $request->get_param( OPENSTATION_WOO_BANDED_PARAM );
586 }
587
588 /**
589 * A readable summary of whether the Products collection is being
590 * band-ordered, for diagnosing a section whose bands look wrong.
591 *
592 * @return array{mode: string, products: int, ordered: int, limit: int}
593 */
594 function openstation_my_wordpress_woo_ordering_state() {
595 $plan = openstation_my_wordpress_woo_product_plan();
596 return array(
597 'mode' => ! empty( $plan['capped'] ) ? 'capped' : 'ordered',
598 'products' => (int) ( $plan['products'] ?? 0 ),
599 'ordered' => count( (array) ( $plan['ids'] ?? array() ) ),
600 'limit' => OPENSTATION_WOO_MAX_ORDERED_PRODUCTS,
601 );
602 }
603
604 /**
605 * Which band a single product belongs to. Stock first, then the first
606 * category that claims it, then the catch-all.
607 *
608 * @param WC_Product $product Product.
609 * @return string Band id.
610 */
611 function openstation_my_wordpress_woo_product_band_id( $product ) {
612 $defs = openstation_my_wordpress_woo_product_band_defs();
613 $stock = $product->get_stock_status();
614
615 foreach ( $defs as $band ) {
616 if ( ! empty( $band['stock'] ) && $band['stock'] === $stock ) {
617 return (string) $band['id'];
618 }
619 }
620
621 $slugs = wp_get_post_terms(
622 $product->get_id(),
623 'product_cat',
624 array( 'fields' => 'slugs' )
625 );
626 $slugs = is_wp_error( $slugs ) ? array() : (array) $slugs;
627
628 foreach ( $defs as $band ) {
629 if ( ! empty( $band['category'] ) && in_array( $band['category'], $slugs, true ) ) {
630 return (string) $band['id'];
631 }
632 }
633
634 return 'cat:__none';
635 }
636
637 /**
638 * Drop the cached band counts when the catalogue changes, so a newly
639 * emptied shelf shows up on the next load rather than five minutes
640 * later.
641 *
642 * @return void
643 */
644 function openstation_my_wordpress_woo_flush_band_counts() {
645 delete_transient( 'desktop_mode_woo_product_plan' );
646 delete_transient( 'desktop_mode_woo_product_total' );
647 delete_transient( 'desktop_mode_woo_coupon_plan' );
648 }
649 add_action( 'woocommerce_update_product', 'openstation_my_wordpress_woo_flush_band_counts' );
650 add_action( 'woocommerce_new_product', 'openstation_my_wordpress_woo_flush_band_counts' );
651 add_action( 'woocommerce_product_set_stock_status', 'openstation_my_wordpress_woo_flush_band_counts' );
652 add_action( 'woocommerce_new_coupon', 'openstation_my_wordpress_woo_flush_band_counts' );
653 add_action( 'woocommerce_update_coupon', 'openstation_my_wordpress_woo_flush_band_counts' );
654 add_action( 'created_product_cat', 'openstation_my_wordpress_woo_flush_band_counts' );
655 add_action( 'edited_product_cat', 'openstation_my_wordpress_woo_flush_band_counts' );
656 add_action( 'delete_product_cat', 'openstation_my_wordpress_woo_flush_band_counts' );
657
658 /**
659 * Order the Products collection so empty shelves come first.
660 *
661 * `wp/v2/product` is core's collection, so ordering has to be pushed
662 * in through its query filter. `_stock_status` sorts
663 * `outofstock` > `onbackorder` > `instock` descending, which is
664 * exactly the band order, so a band's rows arrive together instead of
665 * trickling in across pages.
666 *
667 * @param array $args Query args.
668 * @return array
669 */
670 function openstation_my_wordpress_woo_order_products( $args, $request ) {
671 if ( ! openstation_my_wordpress_woo_active() ) {
672 return $args;
673 }
674 // Only the site window's own requests. `rest_product_query` fires
675 // for every `wp/v2/product` caller — WooCommerce Blocks' Product
676 // Collection renders through it, so rewriting `orderby`
677 // unconditionally would silently replace a storefront's chosen
678 // sort with our band order.
679 if ( ! openstation_my_wordpress_woo_is_banded_request( $request ) ) {
680 return $args;
681 }
682 // A search is the user asking for relevance, not for the band
683 // order — and it would fight the `post__in` clause.
684 if ( ! empty( $request['search'] ) ) {
685 return $args;
686 }
687
688 $plan = openstation_my_wordpress_woo_product_plan();
689 if ( empty( $plan['ids'] ) ) {
690 // Catalogue too large to order this way — fall back to stock
691 // status, which at least floats empty shelves to the top.
692 $args['meta_key'] = '_stock_status'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
693 $args['orderby'] = array(
694 'meta_value' => 'DESC',
695 'date' => 'DESC',
696 );
697 return $args;
698 }
699
700 $args['post__in'] = $plan['ids'];
701 $args['orderby'] = 'post__in';
702 unset( $args['order'] );
703
704 return $args;
705 }
706 // Priority 99, not the default 10. WooCommerce Blocks' own
707 // `ProductQuery::update_rest_query` also hooks this filter at 10 and
708 // ends with `array_merge( $args, …, $orderby_query, … )`, where
709 // `$orderby_query` is rebuilt from the request's `orderby` param —
710 // which defaults to `date`. At equal priority it runs after us and
711 // silently puts `orderby` back, so `post__in` was set but never
712 // honoured and the bands arrived in date order.
713 add_filter( 'rest_product_query', 'openstation_my_wordpress_woo_order_products', 99, 2 );
714
715 /**
716 * Bands for the Coupons section: the ones still worth handing out
717 * first, the dead ones last.
718 *
719 * @return array[]
720 */
721 function openstation_my_wordpress_woo_coupon_bands() {
722 $bands = array(
723 array(
724 'id' => 'coupon:active',
725 'label' => __( 'Active', 'desktop-mode' ),
726 'order' => 10,
727 ),
728 array(
729 'id' => 'coupon:expiring',
730 'label' => __( 'Expiring soon', 'desktop-mode' ),
731 'order' => 20,
732 'tone' => 'warn',
733 ),
734 array(
735 'id' => 'coupon:used-up',
736 'label' => __( 'Usage limit reached', 'desktop-mode' ),
737 'order' => 30,
738 'tone' => 'danger',
739 ),
740 array(
741 'id' => 'coupon:expired',
742 'label' => __( 'Expired', 'desktop-mode' ),
743 'order' => 40,
744 ),
745 );
746
747 /**
748 * Filter the bands the Coupons section groups tiles into.
749 *
750 * **Status: Experimental**
751 *
752 * @param array[] $bands Default bands.
753 */
754 return (array) apply_filters( 'openstation_my_wordpress_woo_coupon_bands', $bands );
755 }
756
757 /**
758 * Which band a coupon belongs to.
759 *
760 * "Expiring soon" is within 30 days — near enough that a merchant
761 * might want to extend or replace it before it lapses.
762 *
763 * @param WC_Coupon $coupon Coupon.
764 * @return string Band id.
765 */
766 function openstation_my_wordpress_woo_coupon_band_id( $coupon ) {
767 $expiry = $coupon->get_date_expires();
768 $limit = (int) $coupon->get_usage_limit();
769 $used = (int) $coupon->get_usage_count();
770
771 if ( $expiry && $expiry->getTimestamp() < time() ) {
772 return 'coupon:expired';
773 }
774 if ( $limit > 0 && $used >= $limit ) {
775 return 'coupon:used-up';
776 }
777 if ( $expiry && $expiry->getTimestamp() < time() + ( 30 * DAY_IN_SECONDS ) ) {
778 return 'coupon:expiring';
779 }
780 return 'coupon:active';
781 }
782
783 /**
784 * The band-ordered coupon id list plus per-band counts.
785 *
786 * Coupon validity lives in postmeta with no single sortable key, so —
787 * as with products — the order is computed once in PHP and replayed as
788 * `post__in`. Stores have tens of coupons, not thousands, so the whole
789 * set is walked.
790 *
791 * @return array{ids: int[], counts: array<string,int>}
792 */
793 function openstation_my_wordpress_woo_coupon_plan() {
794 static $memo = null;
795 if ( null !== $memo ) {
796 return $memo;
797 }
798
799 $cache_key = 'desktop_mode_woo_coupon_plan';
800 $cached = get_transient( $cache_key );
801 if ( is_array( $cached ) && isset( $cached['ids'], $cached['counts'] ) ) {
802 $memo = $cached;
803 return $memo;
804 }
805
806 $ids = get_posts(
807 array(
808 'post_type' => 'shop_coupon',
809 'post_status' => array( 'publish', 'private', 'draft', 'pending', 'future' ),
810 'posts_per_page' => 500,
811 'fields' => 'ids',
812 'orderby' => 'title',
813 'order' => 'ASC',
814 )
815 );
816
817 $buckets = array();
818 foreach ( openstation_my_wordpress_woo_coupon_bands() as $band ) {
819 $buckets[ $band['id'] ] = array();
820 }
821
822 foreach ( (array) $ids as $id ) {
823 $coupon = new WC_Coupon( (int) $id );
824 if ( ! $coupon->get_id() ) {
825 continue;
826 }
827 $band = openstation_my_wordpress_woo_coupon_band_id( $coupon );
828 if ( ! isset( $buckets[ $band ] ) ) {
829 $buckets[ $band ] = array();
830 }
831 $buckets[ $band ][] = (int) $id;
832 }
833
834 $ordered = array();
835 $counts = array();
836 foreach ( $buckets as $band_id => $band_ids ) {
837 $counts[ $band_id ] = count( $band_ids );
838 $ordered = array_merge( $ordered, $band_ids );
839 }
840
841 $memo = array(
842 'ids' => $ordered,
843 'counts' => $counts,
844 );
845 set_transient( $cache_key, $memo, 5 * MINUTE_IN_SECONDS );
846
847 return $memo;
848 }
849
850 /**
851 * Coupon bands with their counts attached, for the bundle.
852 *
853 * @return array[]
854 */
855 function openstation_my_wordpress_woo_coupon_bands_with_counts() {
856 $plan = openstation_my_wordpress_woo_coupon_plan();
857 $bands = openstation_my_wordpress_woo_coupon_bands();
858 foreach ( $bands as $i => $band ) {
859 $bands[ $i ]['count'] = (int) ( $plan['counts'][ $band['id'] ] ?? 0 );
860 }
861 return $bands;
862 }
863
864 /**
865 * Order the Coupons collection to match the band order.
866 *
867 * The bridge controller runs Core's `get_items()`, which applies
868 * `rest_{$post_type}_query` — so the same `post__in` trick works here
869 * even though the collection lives under `desktop-mode/v1`.
870 *
871 * @param array $args Query args.
872 * @param WP_REST_Request $request Request.
873 * @return array
874 */
875 function openstation_my_wordpress_woo_order_coupons( $args, $request ) {
876 if ( ! openstation_my_wordpress_woo_active() || ! empty( $request['search'] ) ) {
877 return $args;
878 }
879 if ( ! openstation_my_wordpress_woo_is_banded_request( $request ) ) {
880 return $args;
881 }
882 $plan = openstation_my_wordpress_woo_coupon_plan();
883 if ( empty( $plan['ids'] ) ) {
884 return $args;
885 }
886 $args['post__in'] = $plan['ids'];
887 $args['orderby'] = 'post__in';
888 unset( $args['order'] );
889 return $args;
890 }
891 add_filter( 'rest_shop_coupon_query', 'openstation_my_wordpress_woo_order_coupons', 99, 2 );
892
893 /**
894 * Expose each coupon's band on its REST row.
895 *
896 * @return void
897 */
898 function openstation_my_wordpress_woo_register_coupon_field() {
899 if ( ! openstation_my_wordpress_woo_active() || ! post_type_exists( 'shop_coupon' ) ) {
900 return;
901 }
902 register_rest_field(
903 'shop_coupon',
904 'openstation_woo',
905 array(
906 'get_callback' => static function ( $post ) {
907 $coupon = new WC_Coupon( isset( $post['id'] ) ? (int) $post['id'] : 0 );
908 if ( ! $coupon->get_id() ) {
909 return null;
910 }
911 return array(
912 'band' => openstation_my_wordpress_woo_coupon_band_id( $coupon ),
913 );
914 },
915 'schema' => array(
916 'description' => __( 'Coupon band used by the site window tiles.', 'desktop-mode' ),
917 'type' => array( 'object', 'null' ),
918 'context' => array( 'view', 'edit' ),
919 'readonly' => true,
920 ),
921 )
922 );
923 }
924 add_action( 'rest_api_init', 'openstation_my_wordpress_woo_register_coupon_field' );
925
926 /**
927 * Expose the few product facts the site window's tiles need — stock
928 * status for the out-of-stock band and badge, category slugs for the
929 * category bands.
930 *
931 * A REST field rather than extra work in the bundle: products come
932 * from core's `wp/v2/product` collection, so this is the only way to
933 * widen that payload. The section declares the field in `listFields`
934 * so `_fields` doesn't strip it back out.
935 *
936 * @return void
937 */
938 function openstation_my_wordpress_woo_register_rest_field() {
939 if ( ! openstation_my_wordpress_woo_active() || ! post_type_exists( 'product' ) ) {
940 return;
941 }
942
943 register_rest_field(
944 'product',
945 'openstation_woo',
946 array(
947 'get_callback' => static function ( $post ) {
948 $product = wc_get_product( isset( $post['id'] ) ? (int) $post['id'] : 0 );
949 if ( ! $product ) {
950 return null;
951 }
952 $slugs = wp_get_post_terms(
953 $product->get_id(),
954 'product_cat',
955 array( 'fields' => 'slugs' )
956 );
957 return array(
958 // The band this row belongs to, decided server-side
959 // by the same rules that ordered the collection, so
960 // the two can't disagree.
961 'band' => openstation_my_wordpress_woo_product_band_id( $product ),
962 'stockStatus' => $product->get_stock_status(),
963 'stockLevel' => $product->managing_stock()
964 ? (int) $product->get_stock_quantity()
965 : null,
966 'onSale' => $product->is_on_sale(),
967 'categories' => is_wp_error( $slugs ) ? array() : array_values( $slugs ),
968 );
969 },
970 'schema' => array(
971 'description' => __( 'Stock and category facts used by the site window tiles.', 'desktop-mode' ),
972 'type' => array( 'object', 'null' ),
973 'context' => array( 'view', 'edit' ),
974 'readonly' => true,
975 ),
976 )
977 );
978 }
979 add_action( 'rest_api_init', 'openstation_my_wordpress_woo_register_rest_field' );
980
981 /**
982 * Boot the integration's hooks. Called from the module bootstrap;
983
984 /**
985 * Boot the integration's hooks. Called from the module bootstrap;
986 * every callback re-checks `openstation_my_wordpress_woo_active()`
987 * because WooCommerce loads on `plugins_loaded`, after this file.
988 *
989 * @return void
990 */
991 function openstation_my_wordpress_woo_boot() {
992 add_filter( 'openstation_my_wordpress_post_type_group', 'openstation_my_wordpress_woo_group', 10, 2 );
993 add_filter( 'openstation_my_wordpress_entities', 'openstation_my_wordpress_woo_entities', 5 );
994 add_filter( 'openstation_my_wordpress_post_type_entity', 'openstation_my_wordpress_woo_entity_icon', 10, 2 );
995 }
996 openstation_my_wordpress_woo_boot();
997
998 /*
999 -------------------------------------------------------------------
1000 * REST
1001 * ----------------------------------------------------------------
1002 */
1003
1004 /**
1005 * Whether the current user may read order data.
1006 *
1007 * @return true|WP_Error
1008 */
1009 function openstation_my_wordpress_woo_orders_permission() {
1010 $orders = get_post_type_object( 'shop_order' );
1011 $cap = $orders instanceof WP_Post_Type && ! empty( $orders->cap->edit_posts )
1012 ? $orders->cap->edit_posts
1013 : 'manage_woocommerce';
1014
1015 if ( ! current_user_can( $cap ) ) {
1016 return new WP_Error(
1017 'openstation_woo_forbidden',
1018 __( 'Sorry, you are not allowed to view orders.', 'desktop-mode' ),
1019 array( 'status' => rest_authorization_required_code() )
1020 );
1021 }
1022 return true;
1023 }
1024
1025 /**
1026 * Shape an order as a post-shaped row so the site window's existing
1027 * list and detail fetchers consume it unchanged.
1028 *
1029 * @param WC_Abstract_Order $order Order.
1030 * @param bool $full Include the `content` field (detail view).
1031 * @return array
1032 */
1033 function openstation_my_wordpress_woo_order_row( $order, $full = false ) {
1034 $total = html_entity_decode(
1035 wp_strip_all_tags( wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ) ),
1036 ENT_QUOTES,
1037 get_bloginfo( 'charset' )
1038 );
1039
1040 $row = array(
1041 'id' => $order->get_id(),
1042 // Tiles show a single line — number and total are what a
1043 // merchant scans for; everything else lives in the pane.
1044 'title' => array(
1045 'rendered' => sprintf(
1046 /* translators: 1: order number, 2: formatted order total. */
1047 __( '#%1$s · %2$s', 'desktop-mode' ),
1048 $order->get_order_number(),
1049 $total
1050 ),
1051 ),
1052 'excerpt' => array( 'rendered' => '' ),
1053 'date' => $order->get_date_created()
1054 ? $order->get_date_created()->date( 'c' )
1055 : '',
1056 // Deliberately `publish`: the tile's status ribbon only speaks
1057 // draft/pending/private/future, and a `wc-processing` value
1058 // would paint a meaningless ribbon on every order. The real
1059 // status is in the pane.
1060 'status' => 'publish',
1061 // Refunds and custom order types don't carry an edit URL.
1062 'link' => method_exists( $order, 'get_edit_order_url' )
1063 ? $order->get_edit_order_url()
1064 : '',
1065 'featured_media' => 0,
1066 // The real status, for the tile bands. Kept out of `status`
1067 // above on purpose — see the note there. Declared in the
1068 // section's `listFields` so `_fields` doesn't strip it.
1069 'wcStatus' => $order->get_status(),
1070 );
1071
1072 if ( $full ) {
1073 $row['content'] = array( 'rendered' => '' );
1074 $row['modified'] = $order->get_date_modified()
1075 ? $order->get_date_modified()->date( 'c' )
1076 : $row['date'];
1077 }
1078
1079 return $row;
1080 }
1081
1082 /**
1083 * Per-band counts for the Orders section, in display order.
1084 *
1085 * Each entry is `array( 'statuses' => string[], 'count' => int )`. The
1086 * catch-all band (the one declaring no statuses) collects every status
1087 * no earlier band claimed, so nothing is dropped and nothing is
1088 * counted twice.
1089 *
1090 * @param array $base Shared `wc_get_orders()` args (search, ordering).
1091 * @return array[]
1092 */
1093 function openstation_my_wordpress_woo_order_band_slices( $base ) {
1094 $all = array_map(
1095 static function ( $status ) {
1096 return substr( $status, 3 ); // Strip the `wc-` prefix.
1097 },
1098 array_keys( wc_get_order_statuses() )
1099 );
1100 $claimed = array();
1101 $slices = array();
1102
1103 foreach ( openstation_my_wordpress_woo_order_bands() as $band ) {
1104 $statuses = array_values(
1105 array_intersect( (array) ( $band['statuses'] ?? array() ), $all )
1106 );
1107 if ( empty( $statuses ) ) {
1108 // Catch-all: whatever no earlier band took.
1109 $statuses = array_values( array_diff( $all, $claimed ) );
1110 }
1111 $claimed = array_merge( $claimed, $statuses );
1112 if ( empty( $statuses ) ) {
1113 continue;
1114 }
1115
1116 $counted = wc_get_orders(
1117 array_merge(
1118 $base,
1119 array(
1120 'status' => array_map(
1121 static function ( $s ) {
1122 return 'wc-' . $s;
1123 },
1124 $statuses
1125 ),
1126 'limit' => 1,
1127 'paginate' => true,
1128 'return' => 'ids',
1129 )
1130 )
1131 );
1132
1133 $slices[] = array(
1134 'statuses' => array_map(
1135 static function ( $s ) {
1136 return 'wc-' . $s;
1137 },
1138 $statuses
1139 ),
1140 'count' => isset( $counted->total ) ? (int) $counted->total : 0,
1141 );
1142 }
1143
1144 return $slices;
1145 }
1146
1147 /**
1148 * `GET /woocommerce/orders` — paginated, post-shaped order list.
1149 *
1150 * @param WP_REST_Request $request Request.
1151 * @return WP_REST_Response
1152 */
1153 function openstation_my_wordpress_woo_orders( $request ) {
1154 $per_page = max( 1, min( 100, (int) ( $request['per_page'] ?? 24 ) ) );
1155 $page = max( 1, (int) ( $request['page'] ?? 1 ) );
1156 $search = (string) ( $request['search'] ?? '' );
1157
1158 $base = array(
1159 'orderby' => 'date',
1160 'order' => 'DESC',
1161 );
1162 if ( '' !== $search ) {
1163 $base['s'] = $search;
1164 }
1165
1166 /**
1167 * Filter the query args for the site window's Orders section.
1168 *
1169 * Merged into every per-band query — `status`, `limit`, `offset`
1170 * and `paginate` are set by the band walker and will be
1171 * overwritten.
1172 *
1173 * **Status: Experimental**
1174 *
1175 * @param array $args `wc_get_orders()` args.
1176 * @param WP_REST_Request $request The request.
1177 */
1178 $base = (array) apply_filters( 'openstation_my_wordpress_woo_order_args', $base, $request );
1179
1180 // Walk the status bands in display order and slice the requested
1181 // page out of the concatenation. Without this the client gets a
1182 // date-ordered page and bands materialise in whatever order their
1183 // first row happens to arrive, so the grouping visibly reshuffles
1184 // as the user scrolls. Ordering server-side means each band's rows
1185 // arrive together, in band order, and a band never appears above
1186 // content the user has already scrolled past.
1187 $slices = openstation_my_wordpress_woo_order_band_slices( $base );
1188
1189 $total = 0;
1190 foreach ( $slices as $slice ) {
1191 $total += $slice['count'];
1192 }
1193 $pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 1;
1194 $offset = ( $page - 1 ) * $per_page;
1195
1196 $orders = array();
1197 $remaining = $per_page;
1198 $cursor = 0;
1199 foreach ( $slices as $slice ) {
1200 if ( $remaining <= 0 ) {
1201 break;
1202 }
1203 $count = $slice['count'];
1204 if ( 0 === $count ) {
1205 continue;
1206 }
1207 // Skip bands that end before the requested offset.
1208 if ( $offset >= $cursor + $count ) {
1209 $cursor += $count;
1210 continue;
1211 }
1212 $within = max( 0, $offset - $cursor );
1213 $take = min( $remaining, $count - $within );
1214
1215 $page_args = array_merge(
1216 $base,
1217 array(
1218 'status' => $slice['statuses'],
1219 'limit' => $take,
1220 'offset' => $within,
1221 'paginate' => false,
1222 )
1223 );
1224 $batch = wc_get_orders( $page_args );
1225 $batch = is_object( $batch ) && isset( $batch->orders )
1226 ? (array) $batch->orders
1227 : (array) $batch;
1228
1229 $orders = array_merge( $orders, $batch );
1230 $remaining -= count( $batch );
1231 $cursor += $count;
1232 $offset = $cursor;
1233 }
1234
1235 $results = (object) array(
1236 'orders' => $orders,
1237 'total' => $total,
1238 'max_num_pages' => max( 1, $pages ),
1239 );
1240
1241 // `wc_get_orders()` returns a plain array unless `paginate` is
1242 // honoured by the active data store. Handle both so a store with
1243 // a custom order data store can't collapse the folder to empty.
1244 if ( is_object( $results ) && isset( $results->orders ) ) {
1245 $orders = (array) $results->orders;
1246 $total = isset( $results->total ) ? (int) $results->total : count( $orders );
1247 $pages = isset( $results->max_num_pages ) ? (int) $results->max_num_pages : 1;
1248 } else {
1249 $orders = (array) $results;
1250 $total = count( $orders );
1251 $pages = 1;
1252 }
1253
1254 $rows = array();
1255 $skipped = 0;
1256 foreach ( $orders as $maybe_order ) {
1257 // A data store may hand back ids rather than objects.
1258 $order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order;
1259
1260 // `WC_Abstract_Order`, not `WC_Order`: that's the type every
1261 // order class actually extends, including HPOS's overrides and
1262 // whatever a custom order type registers. Testing against
1263 // `WC_Order` silently dropped every row on some stores while
1264 // `total` still reported hundreds — an empty folder with a
1265 // confident count on its tile.
1266 if ( ! $order instanceof WC_Abstract_Order ) {
1267 ++$skipped;
1268 continue;
1269 }
1270 try {
1271 $rows[] = openstation_my_wordpress_woo_order_row( $order );
1272 } catch ( Throwable $e ) {
1273 ++$skipped;
1274 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1275 error_log(
1276 sprintf(
1277 '[openstation] Skipped order %d in the site window: %s',
1278 is_object( $order ) ? (int) $order->get_id() : 0,
1279 $e->getMessage()
1280 )
1281 );
1282 }
1283 }
1284
1285 $response = rest_ensure_response( $rows );
1286 $response->header( 'X-WP-Total', (string) $total );
1287 $response->header( 'X-WP-TotalPages', (string) $pages );
1288 // Surfaced so an empty-folder-with-a-count can be diagnosed from
1289 // the network tab instead of guessed at.
1290 $response->header( 'X-Desktop-Mode-Woo-Rows', (string) count( $rows ) );
1291 $response->header( 'X-Desktop-Mode-Woo-Skipped', (string) $skipped );
1292 return $response;
1293 }
1294
1295 /**
1296 * `GET /woocommerce/orders/<id>` — one post-shaped order.
1297 *
1298 * @param WP_REST_Request $request Request.
1299 * @return WP_REST_Response|WP_Error
1300 */
1301 function openstation_my_wordpress_woo_order( $request ) {
1302 $order = wc_get_order( (int) $request['id'] );
1303 if ( ! $order instanceof WC_Abstract_Order ) {
1304 return new WP_Error(
1305 'openstation_woo_no_order',
1306 __( 'Order not found.', 'desktop-mode' ),
1307 array( 'status' => 404 )
1308 );
1309 }
1310 return rest_ensure_response( openstation_my_wordpress_woo_order_row( $order, true ) );
1311 }
1312
1313 /**
1314 * Format an amount in the store's currency, entity-decoded so the
1315 * bundle can render it as text.
1316 *
1317 * @param float $amount Amount.
1318 * @param string|null $currency Currency code.
1319 * @return string
1320 */
1321 function openstation_my_wordpress_woo_price( $amount, $currency = null ) {
1322 $args = $currency ? array( 'currency' => $currency ) : array();
1323 return html_entity_decode(
1324 wp_strip_all_tags( wc_price( (float) $amount, $args ) ),
1325 ENT_QUOTES,
1326 get_bloginfo( 'charset' )
1327 );
1328 }
1329
1330 /**
1331 * Merchant facts for one product.
1332 *
1333 * @param int $id Product id.
1334 * @return array|WP_Error
1335 */
1336 function openstation_my_wordpress_woo_product_summary( $id ) {
1337 $product = wc_get_product( $id );
1338 if ( ! $product ) {
1339 return new WP_Error(
1340 'openstation_woo_no_product',
1341 __( 'Product not found.', 'desktop-mode' ),
1342 array( 'status' => 404 )
1343 );
1344 }
1345
1346 $on_sale = $product->is_on_sale();
1347 $stock_status = $product->get_stock_status();
1348 $stock_labels = array(
1349 'instock' => __( 'In stock', 'desktop-mode' ),
1350 'outofstock' => __( 'Out of stock', 'desktop-mode' ),
1351 'onbackorder' => __( 'On backorder', 'desktop-mode' ),
1352 );
1353
1354 $categories = wp_get_post_terms( $product->get_id(), 'product_cat', array( 'fields' => 'names' ) );
1355
1356 // `wc_get_product_types()` is the registry — there is no
1357 // per-type label helper.
1358 $types = function_exists( 'wc_get_product_types' ) ? wc_get_product_types() : array();
1359 $type = $product->get_type();
1360 $type_label = isset( $types[ $type ] ) ? (string) $types[ $type ] : ucfirst( (string) $type );
1361
1362 return array(
1363 'type' => 'product',
1364 'sku' => $product->get_sku(),
1365 'price' => openstation_my_wordpress_woo_price( $product->get_price() ),
1366 'regular' => $on_sale ? openstation_my_wordpress_woo_price( $product->get_regular_price() ) : '',
1367 'onSale' => $on_sale,
1368 // Raw slug alongside the label: the bundle tints the stock
1369 // pill from the slug, which no translation can break.
1370 'stockStatus' => $stock_status,
1371 'stockLabel' => $stock_labels[ $stock_status ] ?? $stock_status,
1372 'stockLevel' => $product->managing_stock() ? (int) $product->get_stock_quantity() : null,
1373 'sold' => (int) $product->get_total_sales(),
1374 'rating' => (float) $product->get_average_rating(),
1375 'reviews' => (int) $product->get_review_count(),
1376 'productType' => $type_label,
1377 'variations' => $product->is_type( 'variable' ) ? count( $product->get_children() ) : 0,
1378 'categories' => is_wp_error( $categories ) ? array() : array_values( $categories ),
1379 'permalink' => (string) $product->get_permalink(),
1380 'editUrl' => (string) get_edit_post_link( $product->get_id(), 'raw' ),
1381 );
1382 }
1383
1384 /**
1385 * Merchant facts for one order.
1386 *
1387 * @param int $id Order id.
1388 * @return array|WP_Error
1389 */
1390 function openstation_my_wordpress_woo_order_summary( $id ) {
1391 $order = wc_get_order( $id );
1392 if ( ! $order instanceof WC_Abstract_Order ) {
1393 return new WP_Error(
1394 'openstation_woo_no_order',
1395 __( 'Order not found.', 'desktop-mode' ),
1396 array( 'status' => 404 )
1397 );
1398 }
1399
1400 $statuses = wc_get_order_statuses();
1401 $status = 'wc-' . $order->get_status();
1402
1403 $items = array();
1404 foreach ( $order->get_items() as $item ) {
1405 $product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0;
1406 // Variations edit through their parent product's screen.
1407 $edit_id = $product_id;
1408 $items[] = array(
1409 'name' => $item->get_name(),
1410 'quantity' => (int) $item->get_quantity(),
1411 'total' => openstation_my_wordpress_woo_price( $item->get_total(), $order->get_currency() ),
1412 'id' => $product_id,
1413 // A line item whose product has since been deleted has no
1414 // edit screen to link to; the bundle renders plain text.
1415 'editUrl' => $edit_id && get_post( $edit_id )
1416 ? (string) get_edit_post_link( $edit_id, 'raw' )
1417 : '',
1418 );
1419 }
1420
1421 // Refunds and custom order types don't carry billing accessors.
1422 $name = method_exists( $order, 'get_formatted_billing_full_name' )
1423 ? trim( $order->get_formatted_billing_full_name() )
1424 : '';
1425 $customer_id = method_exists( $order, 'get_customer_id' )
1426 ? (int) $order->get_customer_id()
1427 : 0;
1428
1429 return array(
1430 'type' => 'order',
1431 'number' => $order->get_order_number(),
1432 'status' => $order->get_status(),
1433 'statusLabel' => $statuses[ $status ] ?? $order->get_status(),
1434 'total' => openstation_my_wordpress_woo_price( $order->get_total(), $order->get_currency() ),
1435 'subtotal' => openstation_my_wordpress_woo_price( $order->get_subtotal(), $order->get_currency() ),
1436 'shipping' => (float) $order->get_shipping_total() > 0
1437 ? openstation_my_wordpress_woo_price( $order->get_shipping_total(), $order->get_currency() )
1438 : '',
1439 'discount' => (float) $order->get_discount_total() > 0
1440 ? openstation_my_wordpress_woo_price( $order->get_discount_total(), $order->get_currency() )
1441 : '',
1442 'coupons' => array_values(
1443 array_map(
1444 static function ( $coupon ) {
1445 return $coupon->get_code();
1446 },
1447 $order->get_items( 'coupon' )
1448 )
1449 ),
1450 'paymentVia' => $order->get_payment_method_title(),
1451 'datePaid' => $order->get_date_paid() ? $order->get_date_paid()->date( 'c' ) : '',
1452 'placed' => $order->get_date_created() ? $order->get_date_created()->date( 'c' ) : '',
1453 'customer' => '' !== $name ? $name : __( 'Guest', 'desktop-mode' ),
1454 'customerUrl' => $customer_id && current_user_can( 'edit_user', $customer_id )
1455 ? (string) get_edit_user_link( $customer_id )
1456 : '',
1457 'email' => $order->get_billing_email(),
1458 'itemCount' => $order->get_item_count(),
1459 'items' => $items,
1460 'editUrl' => method_exists( $order, 'get_edit_order_url' )
1461 ? $order->get_edit_order_url()
1462 : '',
1463 );
1464 }
1465
1466 /**
1467 * Total discount a coupon has actually given customers — the number a
1468 * merchant wants and WooCommerce never surfaces.
1469 *
1470 * There's no aggregate for this, so it means walking paid orders and
1471 * summing the matching coupon line items. Bounded to the most recent
1472 * 500 orders and cached, because the coupon preview pane hits this on
1473 * every selection and the scan is by far the most expensive thing in
1474 * the summary.
1475 *
1476 * @param WC_Coupon $coupon Coupon.
1477 * @return float Total discount given.
1478 */
1479 function openstation_my_wordpress_woo_coupon_discount_given( $coupon ) {
1480 $cache_key = 'openstation_woo_coupon_given_' . $coupon->get_id();
1481 $cached = get_transient( $cache_key );
1482 if ( false !== $cached ) {
1483 return (float) $cached;
1484 }
1485
1486 $granted = 0.0;
1487 $code = strtolower( $coupon->get_code() );
1488 $orders = wc_get_orders(
1489 array(
1490 'limit' => 500,
1491 'status' => array( 'wc-processing', 'wc-completed' ),
1492 'return' => 'objects',
1493 )
1494 );
1495 foreach ( (array) $orders as $maybe_order ) {
1496 $order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order;
1497 if ( ! $order instanceof WC_Abstract_Order ) {
1498 continue;
1499 }
1500 foreach ( $order->get_items( 'coupon' ) as $line ) {
1501 if ( strtolower( $line->get_code() ) === $code ) {
1502 $granted += (float) $line->get_discount();
1503 }
1504 }
1505 }
1506
1507 set_transient( $cache_key, $granted, 5 * MINUTE_IN_SECONDS );
1508
1509 return $granted;
1510 }
1511
1512 /**
1513 * Merchant facts for one coupon.
1514 *
1515 * @param int $id Coupon id.
1516 * @return array|WP_Error
1517 */
1518 function openstation_my_wordpress_woo_coupon_summary( $id ) {
1519 $coupon = new WC_Coupon( $id );
1520 if ( ! $coupon->get_id() ) {
1521 return new WP_Error(
1522 'openstation_woo_no_coupon',
1523 __( 'Coupon not found.', 'desktop-mode' ),
1524 array( 'status' => 404 )
1525 );
1526 }
1527
1528 $expiry = $coupon->get_date_expires();
1529 $limit = (int) $coupon->get_usage_limit();
1530 $used = (int) $coupon->get_usage_count();
1531
1532 // "Active" is expiry + usage-limit only. WooCommerce's full
1533 // validity check needs a cart to run against, and a coupon that
1534 // merely doesn't apply to the current (empty) cart isn't inactive.
1535 $expired = $expiry && $expiry->getTimestamp() < time();
1536 $used_up = $limit > 0 && $used >= $limit;
1537
1538 $type_label = 'percent' === $coupon->get_discount_type()
1539 ? sprintf(
1540 /* translators: %s: percentage off. */
1541 __( '%s%% off', 'desktop-mode' ),
1542 wc_format_localized_decimal( $coupon->get_amount() )
1543 )
1544 : sprintf(
1545 /* translators: %s: formatted discount amount. */
1546 __( '%s off', 'desktop-mode' ),
1547 openstation_my_wordpress_woo_price( $coupon->get_amount() )
1548 );
1549
1550 // Resolve product / category restrictions to names with links —
1551 // WooCommerce's own coupon screen shows these as bare token
1552 // fields you have to click into.
1553 $link_terms = static function ( array $ids, $taxonomy ) {
1554 $out = array();
1555 foreach ( $ids as $id ) {
1556 $term = get_term( (int) $id, $taxonomy );
1557 if ( $term && ! is_wp_error( $term ) ) {
1558 $out[] = array(
1559 'label' => $term->name,
1560 'editUrl' => (string) get_edit_term_link( $term->term_id, $taxonomy ),
1561 );
1562 }
1563 }
1564 return $out;
1565 };
1566
1567 $link_products = static function ( array $ids ) {
1568 $out = array();
1569 foreach ( $ids as $id ) {
1570 $product = wc_get_product( (int) $id );
1571 if ( $product ) {
1572 $out[] = array(
1573 'label' => $product->get_name(),
1574 'editUrl' => (string) get_edit_post_link( $product->get_id(), 'raw' ),
1575 );
1576 }
1577 }
1578 return $out;
1579 };
1580
1581 $granted = openstation_my_wordpress_woo_coupon_discount_given( $coupon );
1582
1583 return array(
1584 'type' => 'coupon',
1585 'code' => $coupon->get_code(),
1586 'active' => ! $expired && ! $used_up,
1587 'inactiveWhy' => $expired
1588 ? __( 'Expired', 'desktop-mode' )
1589 : ( $used_up ? __( 'Usage limit reached', 'desktop-mode' ) : '' ),
1590 'discount' => $type_label,
1591 'description' => $coupon->get_description(),
1592 'used' => $used,
1593 'usageLimit' => $limit,
1594 'perUserLimit' => (int) $coupon->get_usage_limit_per_user(),
1595 'limitToItems' => (int) $coupon->get_limit_usage_to_x_items(),
1596 'granted' => $granted > 0 ? openstation_my_wordpress_woo_price( $granted ) : '',
1597 'created' => $coupon->get_date_created() ? $coupon->get_date_created()->date( 'c' ) : '',
1598 'expires' => $expiry ? $expiry->date( 'c' ) : '',
1599 'minSpend' => $coupon->get_minimum_amount()
1600 ? openstation_my_wordpress_woo_price( $coupon->get_minimum_amount() )
1601 : '',
1602 'maxSpend' => $coupon->get_maximum_amount()
1603 ? openstation_my_wordpress_woo_price( $coupon->get_maximum_amount() )
1604 : '',
1605 'freeShipping' => (bool) $coupon->get_free_shipping(),
1606 'individualUse' => (bool) $coupon->get_individual_use(),
1607 'excludeSale' => (bool) $coupon->get_exclude_sale_items(),
1608 'products' => $link_products( (array) $coupon->get_product_ids() ),
1609 'excluded' => $link_products( (array) $coupon->get_excluded_product_ids() ),
1610 'categories' => $link_terms( (array) $coupon->get_product_categories(), 'product_cat' ),
1611 'emails' => array_values( (array) $coupon->get_email_restrictions() ),
1612 'editUrl' => (string) get_edit_post_link( $coupon->get_id(), 'raw' ),
1613 );
1614 }
1615
1616 /**
1617 * `GET /woocommerce/summary/<type>/<id>`.
1618 *
1619 * @param WP_REST_Request $request Request.
1620 * @return WP_REST_Response|WP_Error
1621 */
1622 function openstation_my_wordpress_woo_summary( $request ) {
1623 $type = (string) $request['type'];
1624 $id = (int) $request['id'];
1625
1626 switch ( $type ) {
1627 case 'product':
1628 $data = openstation_my_wordpress_woo_product_summary( $id );
1629 break;
1630 case 'order':
1631 $data = openstation_my_wordpress_woo_order_summary( $id );
1632 break;
1633 case 'coupon':
1634 $data = openstation_my_wordpress_woo_coupon_summary( $id );
1635 break;
1636 default:
1637 /**
1638 * Filter in a summary payload for a type this route
1639 * doesn't handle itself.
1640 *
1641 * The route is the one place the site window asks "tell
1642 * me about this shop object", so a new object type — a
1643 * customer, a subscription, a booking — joins here rather
1644 * than needing its own endpoint and its own client
1645 * transport. Return `null` (the default) to leave the
1646 * type unknown, which answers 400.
1647 *
1648 * A subscriber MUST also gate its type in
1649 * `openstation_my_wordpress_woo_summary_capability`,
1650 * which decides who may ask.
1651 *
1652 * **Status: Experimental**
1653 *
1654 * @param array|null $data Summary payload, or `null`.
1655 * @param string $type The requested type.
1656 * @param int $id Object id.
1657 */
1658 $data = apply_filters( 'openstation_my_wordpress_woo_summary_type', null, $type, $id );
1659
1660 if ( ! is_array( $data ) && ! is_wp_error( $data ) ) {
1661 return new WP_Error(
1662 'openstation_woo_bad_type',
1663 __( 'Unknown summary type.', 'desktop-mode' ),
1664 array( 'status' => 400 )
1665 );
1666 }
1667 break;
1668 }
1669
1670 if ( is_wp_error( $data ) ) {
1671 return $data;
1672 }
1673
1674 /**
1675 * Filter the merchant summary shown in the site window's right
1676 * pane for a product, order, or coupon.
1677 *
1678 * **Status: Experimental**
1679 *
1680 * @param array $data Summary payload.
1681 * @param string $type One of `product`, `order`, `coupon`.
1682 * @param int $id Object id.
1683 */
1684 return rest_ensure_response(
1685 (array) apply_filters( 'openstation_my_wordpress_woo_summary', $data, $type, $id )
1686 );
1687 }
1688
1689 /**
1690 * Permission check for a summary request — the capability depends on
1691 * what is being summarised.
1692 *
1693 * @param WP_REST_Request $request Request.
1694 * @return true|WP_Error
1695 */
1696 function openstation_my_wordpress_woo_summary_permission( $request ) {
1697 $type = (string) $request['type'];
1698 $id = (int) $request['id'];
1699
1700 if ( 'order' === $type ) {
1701 return openstation_my_wordpress_woo_orders_permission();
1702 }
1703
1704 /**
1705 * Filter the permission check for a summary type this route
1706 * doesn't handle itself.
1707 *
1708 * Return `true` to allow, a `WP_Error` to deny, or `null` (the
1709 * default) to fall through to the post-capability check below —
1710 * which is only meaningful for types whose id IS a post id. Any
1711 * type added through
1712 * `openstation_my_wordpress_woo_summary_type` must answer here
1713 * too, or it inherits a capability check that means nothing for
1714 * it.
1715 *
1716 * **Status: Experimental**
1717 *
1718 * @param true|WP_Error|null $allowed Permission verdict.
1719 * @param string $type The requested type.
1720 * @param int $id Object id.
1721 */
1722 $allowed = apply_filters( 'openstation_my_wordpress_woo_summary_capability', null, $type, $id );
1723 if ( true === $allowed || is_wp_error( $allowed ) ) {
1724 return $allowed;
1725 }
1726
1727 if ( ! current_user_can( 'edit_post', $id ) ) {
1728 return new WP_Error(
1729 'openstation_woo_forbidden',
1730 __( 'Sorry, you are not allowed to view this item.', 'desktop-mode' ),
1731 array( 'status' => rest_authorization_required_code() )
1732 );
1733 }
1734 return true;
1735 }
1736
1737 /**
1738 * `GET /woocommerce/store` — headline numbers for the Woo folder.
1739 *
1740 * Deliberately three cheap queries: a revenue sum over paid orders
1741 * this month, a count of orders awaiting action, and a low-stock
1742 * count. Anything heavier belongs in WooCommerce Analytics.
1743 *
1744 * @return WP_REST_Response
1745 */
1746 function openstation_my_wordpress_woo_store() {
1747 $month_start = gmdate( 'Y-m-01 00:00:00', current_time( 'timestamp' ) );
1748
1749 $paid = wc_get_orders(
1750 array(
1751 'limit' => -1,
1752 'status' => array( 'wc-processing', 'wc-completed' ),
1753 'date_created' => '>=' . $month_start,
1754 'return' => 'objects',
1755 )
1756 );
1757
1758 $revenue = 0.0;
1759 foreach ( (array) $paid as $maybe_order ) {
1760 $order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order;
1761 if ( $order instanceof WC_Abstract_Order ) {
1762 $revenue += (float) $order->get_total();
1763 }
1764 }
1765
1766 // Read the statuses straight off the "Needs attention" band rather
1767 // than repeating them. They had drifted: the band counted
1768 // pending + processing + on-hold while this counted only the last
1769 // two, so the folder and the panel disagreed by every pending
1770 // order on the store.
1771 $attention = array();
1772 foreach ( openstation_my_wordpress_woo_order_bands() as $band ) {
1773 if ( 'needs-action' === ( $band['id'] ?? '' ) ) {
1774 $attention = array_map(
1775 static function ( $status ) {
1776 return 'wc-' . $status;
1777 },
1778 (array) ( $band['statuses'] ?? array() )
1779 );
1780 break;
1781 }
1782 }
1783 if ( empty( $attention ) ) {
1784 $attention = array( 'wc-processing', 'wc-on-hold', 'wc-pending' );
1785 }
1786
1787 $processing = wc_get_orders(
1788 array(
1789 'limit' => 1,
1790 'paginate' => true,
1791 'return' => 'ids',
1792 'status' => $attention,
1793 )
1794 );
1795
1796 $low_stock = wc_get_products(
1797 array(
1798 'limit' => -1,
1799 'return' => 'ids',
1800 'stock_status' => 'outofstock',
1801 )
1802 );
1803
1804 $data = array(
1805 'revenue' => openstation_my_wordpress_woo_price( $revenue ),
1806 'revenueRaw' => round( $revenue, 2 ),
1807 'processing' => isset( $processing->total ) ? (int) $processing->total : 0,
1808 'outOfStock' => is_array( $low_stock ) ? count( $low_stock ) : 0,
1809 'ordersUrl' => admin_url( 'admin.php?page=wc-orders' ),
1810 );
1811
1812 /**
1813 * Filter the store headline numbers shown on the Woo folder.
1814 *
1815 * **Status: Experimental**
1816 *
1817 * @param array $data Store totals.
1818 */
1819 return rest_ensure_response(
1820 (array) apply_filters( 'openstation_my_wordpress_woo_store', $data )
1821 );
1822 }
1823
1824 /**
1825 * Register the integration's REST routes.
1826 *
1827 * @return void
1828 */
1829 function openstation_my_wordpress_woo_register_routes() {
1830 if ( ! openstation_my_wordpress_woo_active() ) {
1831 return;
1832 }
1833
1834 register_rest_route(
1835 'desktop-mode/v1',
1836 '/woocommerce/orders',
1837 array(
1838 'methods' => WP_REST_Server::READABLE,
1839 'callback' => 'openstation_my_wordpress_woo_orders',
1840 'permission_callback' => 'openstation_my_wordpress_woo_orders_permission',
1841 'args' => array(
1842 'page' => array(
1843 'type' => 'integer',
1844 'default' => 1,
1845 ),
1846 'per_page' => array(
1847 'type' => 'integer',
1848 'default' => 24,
1849 ),
1850 'search' => array( 'type' => 'string' ),
1851 ),
1852 )
1853 );
1854
1855 register_rest_route(
1856 'desktop-mode/v1',
1857 '/woocommerce/orders/(?P<id>\d+)',
1858 array(
1859 'methods' => WP_REST_Server::READABLE,
1860 'callback' => 'openstation_my_wordpress_woo_order',
1861 'permission_callback' => 'openstation_my_wordpress_woo_orders_permission',
1862 'args' => array(
1863 'id' => array( 'type' => 'integer' ),
1864 ),
1865 )
1866 );
1867
1868 register_rest_route(
1869 'desktop-mode/v1',
1870 '/woocommerce/summary/(?P<type>[a-z]+)/(?P<id>\d+)',
1871 array(
1872 'methods' => WP_REST_Server::READABLE,
1873 'callback' => 'openstation_my_wordpress_woo_summary',
1874 'permission_callback' => 'openstation_my_wordpress_woo_summary_permission',
1875 'args' => array(
1876 'type' => array( 'type' => 'string' ),
1877 'id' => array( 'type' => 'integer' ),
1878 ),
1879 )
1880 );
1881
1882 register_rest_route(
1883 'desktop-mode/v1',
1884 '/woocommerce/store',
1885 array(
1886 'methods' => WP_REST_Server::READABLE,
1887 'callback' => 'openstation_my_wordpress_woo_store',
1888 'permission_callback' => 'openstation_my_wordpress_woo_orders_permission',
1889 )
1890 );
1891 }
1892 add_action( 'rest_api_init', 'openstation_my_wordpress_woo_register_routes' );
1893
1894 /*
1895 -------------------------------------------------------------------
1896 * Assets
1897 * ----------------------------------------------------------------
1898 */
1899
1900 /**
1901 * Register the integration bundle.
1902 *
1903 * Cache-busted by `filemtime`, like the window bundle it rides along
1904 * with (see `openstation_my_wordpress_register_assets()`). The bundle
1905 * is fetched lazily by URL, so a `ver` that only moves on release
1906 * would let a browser's cached copy outlive builds within one — a
1907 * stale companion against a fresh WP Explorer bundle is a contract
1908 * drift no error message points at.
1909 *
1910 * @return void
1911 */
1912 function openstation_my_wordpress_woo_register_assets() {
1913 $js_path = OPENSTATION_DIR . 'assets/js/my-wordpress-woocommerce' . openstation_asset_suffix() . '.js';
1914 wp_register_script(
1915 'os-my-wordpress-woocommerce',
1916 OPENSTATION_URL . 'assets/js/my-wordpress-woocommerce' . openstation_asset_suffix() . '.js',
1917 array( 'wp-hooks' ),
1918 file_exists( $js_path ) ? (string) filemtime( $js_path ) : OPENSTATION_VERSION,
1919 true
1920 );
1921 wp_set_script_translations( 'os-my-wordpress-woocommerce', 'desktop-mode' );
1922
1923 $css_path = OPENSTATION_DIR . 'assets/css/my-wordpress-woocommerce.css';
1924 wp_register_style(
1925 'os-my-wordpress-woocommerce',
1926 OPENSTATION_URL . 'assets/css/my-wordpress-woocommerce.css',
1927 array( 'desktop-mode-my-wordpress' ),
1928 file_exists( $css_path ) ? (string) filemtime( $css_path ) : OPENSTATION_VERSION
1929 );
1930 }
1931 add_action( 'init', 'openstation_my_wordpress_woo_register_assets', 5 );
1932
1933 /**
1934 * Attach the integration's config to its script handle.
1935 *
1936 * NOTHING is enqueued here, and that is the point. The bundle
1937 * subscribes to the WP Explorer window's `preview-extras` /
1938 * `group-extras` actions, so it has to be in the tab before that
1939 * window's bundle paints — but not one moment sooner. It travels as
1940 * a companion of `desktop-mode-my-wordpress` (see the `scripts` arg
1941 * on that window's registration), which means the shell loads it
1942 * when the window first opens and a merchant who never opens WP
1943 * Explorer never downloads it at all. The stylesheet travels the
1944 * same way (the `styles` arg): every selector in it is scoped to
1945 * surfaces inside the Explorer or the Customer window, so on any
1946 * document not showing those — every chromeless iframe included —
1947 * it was pure parse weight.
1948 *
1949 * Only for users who can open the site window on a store — everyone
1950 * else pays nothing.
1951 *
1952 * Runs at priority 5, ahead of `openstation_enqueue_assets()` at 10:
1953 * that is where the boot payload is built, and it harvests this
1954 * inline blob off the registered handle so the lazy loader can
1955 * replay it around the script tag. Attaching later would ship the
1956 * bundle with no config.
1957 *
1958 * @return void
1959 */
1960 function openstation_my_wordpress_woo_enqueue() {
1961 if ( ! openstation_my_wordpress_woo_active() ) {
1962 return;
1963 }
1964 if ( ! function_exists( 'openstation_is_enabled' ) || ! openstation_is_enabled() ) {
1965 return;
1966 }
1967 if ( ! openstation_my_wordpress_user_can_use() ) {
1968 return;
1969 }
1970
1971 wp_add_inline_script(
1972 'os-my-wordpress-woocommerce',
1973 sprintf(
1974 'window.openStationWooConfig=%s;',
1975 wp_json_encode(
1976 array(
1977 'restRoot' => esc_url_raw( rest_url( 'desktop-mode/v1/woocommerce/' ) ),
1978 'restNonce' => wp_create_nonce( 'wp_rest' ),
1979 'canOrders' => true === openstation_my_wordpress_woo_orders_permission(),
1980 'canCustomers' => true === openstation_my_wordpress_woo_customers_permission(),
1981 'orderBands' => openstation_my_wordpress_woo_order_bands(),
1982 'productBands' => openstation_my_wordpress_woo_product_bands(),
1983 'couponBands' => openstation_my_wordpress_woo_coupon_bands_with_counts(),
1984 // Only built for a viewer who may see them — the
1985 // band counts are money, and the plan behind them
1986 // is a full pass over the user base.
1987 'customerBands' => true === openstation_my_wordpress_woo_customers_permission()
1988 ? openstation_my_wordpress_woo_customer_bands_with_counts()
1989 : array(),
1990 // Whether the catalogue is small enough to be
1991 // band-ordered server-side. Read it from the
1992 // console (`window.openStationWooConfig.ordering`)
1993 // when the Products bands look wrong: `capped`
1994 // means rows arrive stock-ordered only and the
1995 // category bands fill progressively.
1996 'ordering' => openstation_my_wordpress_woo_ordering_state(),
1997 )
1998 )
1999 ),
2000 'before'
2001 );
2002 }
2003 add_action( 'admin_enqueue_scripts', 'openstation_my_wordpress_woo_enqueue', 5 );
2004
2005 /**
2006 * Attach the bundle and stylesheet to the WP Explorer window as
2007 * companions.
2008 *
2009 * `scripts` handles load in order immediately before the window's own
2010 * `script`, so the integration is listening to `preview-extras` /
2011 * `group-extras` by the time the window bundle fires them — the same
2012 * guarantee the old boot-time enqueue gave, at the cost of nothing
2013 * until the window opens.
2014 *
2015 * `styles` handles inject on the same first open, in ARRAY ORDER —
2016 * and that order is the whole ballgame here: this sheet overrides
2017 * `my-wordpress.css` at EQUAL specificity (the ribbon and panel
2018 * chrome), which the old enqueue path guaranteed through a
2019 * `wp_register_style` dependency. The Explorer's registration
2020 * declares its own sheet first in `styles` (see the comment in
2021 * `includes/my-wordpress/window.php`), this filter APPENDS, so the
2022 * Woo sheet lands after it in `<head>` and wins by source order.
2023 * Prepending here would silently invert the cascade.
2024 *
2025 * @param array $window_args Args passed to `openstation_register_window()`.
2026 * @return array
2027 */
2028 function openstation_my_wordpress_woo_window_args( $window_args ) {
2029 if ( ! is_array( $window_args ) || ! openstation_my_wordpress_woo_active() ) {
2030 return $window_args;
2031 }
2032
2033 $scripts = isset( $window_args['scripts'] ) ? (array) $window_args['scripts'] : array();
2034 $scripts[] = 'os-my-wordpress-woocommerce';
2035
2036 $styles = isset( $window_args['styles'] ) ? (array) $window_args['styles'] : array();
2037 $styles[] = 'os-my-wordpress-woocommerce';
2038
2039 $window_args['scripts'] = $scripts;
2040 $window_args['styles'] = $styles;
2041
2042 return $window_args;
2043 }
2044 add_filter( 'openstation_my_wordpress_window_args', 'openstation_my_wordpress_woo_window_args' );
2045