| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — My WordPress: WooCommerce **Customers**. |
| 4 |
* |
| 5 |
* WooCommerce ships two views of the people who buy from a store, and |
| 6 |
* neither is a place you can work from: `users.php` is a role list that |
| 7 |
* knows nothing about money, and Analytics → Customers is a report you |
| 8 |
* read and then leave. Neither one opens next to the order it explains. |
| 9 |
* |
| 10 |
* This file adds a **Customers** section to the Woo folder in the site |
| 11 |
* window. It renders through the existing `user` entity kind — avatar |
| 12 |
* tiles, the dossier preview, the drag-out seam, the footprint route — |
| 13 |
* so a customer is a first-class object on the desktop: drag one onto |
| 14 |
* the wallpaper, open their orders beside their profile, tie the |
| 15 |
* windows together with the relations layer. |
| 16 |
* |
| 17 |
* What makes it a *customer* list rather than a user list is the |
| 18 |
* `openstation_woo_customer` payload on every row: lifetime spend, |
| 19 |
* order count, average order value, first and last order, days since |
| 20 |
* the last one, and the band that summarises all of it. |
| 21 |
* |
| 22 |
* ## Bands |
| 23 |
* |
| 24 |
* Ordered so the two bands a merchant can *act on* come first: |
| 25 |
* |
| 26 |
* 1. **VIP** — spend at or above the VIP threshold (three times |
| 27 |
* the store's average order value by default). Who |
| 28 |
* to look after. |
| 29 |
* 2. **Lapsed** — has ordered, but not within the lapse window (180 |
| 30 |
* days by default). Who to win back. |
| 31 |
* 3. **Repeat** — two or more orders, still active. |
| 32 |
* 4. **New** — exactly one order. |
| 33 |
* 5. **No orders** — registered, never bought. |
| 34 |
* |
| 35 |
* ## Who appears |
| 36 |
* |
| 37 |
* Every user who has placed a paid order, plus every user holding the |
| 38 |
* `customer` role. Guests (orders with no account) have no user to |
| 39 |
* render — their revenue is reported as a single line on the Woo |
| 40 |
* folder's Store panel instead of being silently dropped. |
| 41 |
* |
| 42 |
* ## Cost |
| 43 |
* |
| 44 |
* One grouped query over the order store gives every customer's |
| 45 |
* aggregate at once — order count, spend, first and last order date — |
| 46 |
* cached for five minutes and flushed whenever an order changes. The |
| 47 |
* band ordering, the per-row facts and the folder counts all read that |
| 48 |
* one map, so a page of customers costs no per-row order queries at |
| 49 |
* all. Stores past `OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS` users skip |
| 50 |
* the band ordering and fall back to newest-first, exactly like the |
| 51 |
* catalogue does past its own cap. |
| 52 |
* |
| 53 |
* REST surface (read-only, gated on `list_users` + order access): |
| 54 |
* |
| 55 |
* GET desktop-mode/v1/woocommerce/customers |
| 56 |
* GET desktop-mode/v1/woocommerce/customers/<id> |
| 57 |
* GET desktop-mode/v1/woocommerce/summary/customer/<id> |
| 58 |
* |
| 59 |
* @package OpenStation |
| 60 |
*/ |
| 61 |
|
| 62 |
defined( 'ABSPATH' ) || exit; |
| 63 |
|
| 64 |
/** |
| 65 |
* Above this many candidate customers the section stops band-ordering |
| 66 |
* and falls back to newest-registered-first. The ordering plan holds |
| 67 |
* one id per customer in a transient; the cap is what keeps that |
| 68 |
* option from growing without bound on a store with a large user base. |
| 69 |
*/ |
| 70 |
const OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS = 5000; |
| 71 |
|
| 72 |
/** |
| 73 |
* Days without an order after which a customer counts as lapsed. |
| 74 |
* Filterable through `openstation_my_wordpress_woo_customer_lapse_days`. |
| 75 |
*/ |
| 76 |
const OPENSTATION_WOO_CUSTOMER_LAPSE_DAYS = 180; |
| 77 |
|
| 78 |
/* |
| 79 |
------------------------------------------------------------------- |
| 80 |
* Aggregates |
| 81 |
* ---------------------------------------------------------------- |
| 82 |
*/ |
| 83 |
|
| 84 |
/** |
| 85 |
* Order statuses that count as money actually taken. |
| 86 |
* |
| 87 |
* Mirrors `wc_get_customer_total_spent()`, so the lifetime spend on a |
| 88 |
* tile agrees with the number WooCommerce itself would report. |
| 89 |
* |
| 90 |
* @return string[] Statuses WITH the `wc-` prefix. |
| 91 |
*/ |
| 92 |
function openstation_my_wordpress_woo_paid_statuses() { |
| 93 |
$statuses = function_exists( 'wc_get_is_paid_statuses' ) |
| 94 |
? (array) wc_get_is_paid_statuses() |
| 95 |
: array( 'processing', 'completed' ); |
| 96 |
|
| 97 |
return array_values( |
| 98 |
array_map( |
| 99 |
static function ( $status ) { |
| 100 |
return 0 === strpos( (string) $status, 'wc-' ) ? (string) $status : 'wc-' . $status; |
| 101 |
}, |
| 102 |
$statuses |
| 103 |
) |
| 104 |
); |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* Whether the store keeps orders in WooCommerce's own tables (HPOS) |
| 109 |
* rather than in `wp_posts`. |
| 110 |
* |
| 111 |
* @return bool |
| 112 |
*/ |
| 113 |
function openstation_my_wordpress_woo_hpos_enabled() { |
| 114 |
return class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) |
| 115 |
&& \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); |
| 116 |
} |
| 117 |
|
| 118 |
/** |
| 119 |
* Every customer's order aggregate, in one query. |
| 120 |
* |
| 121 |
* The whole section — band definitions, band ordering, per-row facts, |
| 122 |
* folder counts — reads this one map. Doing it per user would be one |
| 123 |
* or two queries per tile, and the band ordering would need the whole |
| 124 |
* user base walked before the first tile could paint. |
| 125 |
* |
| 126 |
* Guest orders (no account) are aggregated under the `0` key. They can |
| 127 |
* never appear as a tile — there is no user to render — but their |
| 128 |
* revenue is real and the Store panel reports it rather than letting |
| 129 |
* it vanish. |
| 130 |
* |
| 131 |
* @return array<int, array{orders:int, spend:float, first:string, last:string}> |
| 132 |
* Keyed by user id; `0` holds the guest aggregate. |
| 133 |
*/ |
| 134 |
function openstation_my_wordpress_woo_customer_spend_map() { |
| 135 |
static $memo = null; |
| 136 |
if ( null !== $memo ) { |
| 137 |
return $memo; |
| 138 |
} |
| 139 |
|
| 140 |
$cached = get_transient( 'desktop_mode_woo_customer_spend' ); |
| 141 |
if ( is_array( $cached ) ) { |
| 142 |
$memo = $cached; |
| 143 |
return $memo; |
| 144 |
} |
| 145 |
|
| 146 |
global $wpdb; |
| 147 |
|
| 148 |
$statuses = openstation_my_wordpress_woo_paid_statuses(); |
| 149 |
$placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ); |
| 150 |
|
| 151 |
if ( openstation_my_wordpress_woo_hpos_enabled() ) { |
| 152 |
$table = $wpdb->prefix . 'wc_orders'; |
| 153 |
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name and the placeholder list are structural; every value is prepared. |
| 154 |
$sql = $wpdb->prepare( |
| 155 |
"SELECT customer_id AS uid, |
| 156 |
COUNT(*) AS orders, |
| 157 |
SUM(total_amount) AS spend, |
| 158 |
MIN(date_created_gmt) AS first_order, |
| 159 |
MAX(date_created_gmt) AS last_order |
| 160 |
FROM {$table} |
| 161 |
WHERE type = 'shop_order' AND status IN ( {$placeholders} ) |
| 162 |
GROUP BY customer_id", |
| 163 |
$statuses |
| 164 |
); |
| 165 |
} else { |
| 166 |
// Legacy storage: `_customer_user` is the account id (0 for a |
| 167 |
// guest) and `_order_total` the gross. `+0` casts the meta |
| 168 |
// strings so SUM/comparison behave numerically. |
| 169 |
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- ditto. |
| 170 |
$sql = $wpdb->prepare( |
| 171 |
"SELECT cu.meta_value + 0 AS uid, |
| 172 |
COUNT(*) AS orders, |
| 173 |
SUM( tot.meta_value + 0 ) AS spend, |
| 174 |
MIN(p.post_date_gmt) AS first_order, |
| 175 |
MAX(p.post_date_gmt) AS last_order |
| 176 |
FROM {$wpdb->posts} p |
| 177 |
INNER JOIN {$wpdb->postmeta} cu ON cu.post_id = p.ID AND cu.meta_key = '_customer_user' |
| 178 |
LEFT JOIN {$wpdb->postmeta} tot ON tot.post_id = p.ID AND tot.meta_key = '_order_total' |
| 179 |
WHERE p.post_type = 'shop_order' AND p.post_status IN ( {$placeholders} ) |
| 180 |
GROUP BY cu.meta_value", |
| 181 |
$statuses |
| 182 |
); |
| 183 |
} |
| 184 |
|
| 185 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- one grouped aggregate with no core API equivalent; $sql came out of $wpdb->prepare() above, and the result is cached in the transient below. |
| 186 |
$rows = $wpdb->get_results( $sql ); |
| 187 |
|
| 188 |
$map = array(); |
| 189 |
foreach ( (array) $rows as $row ) { |
| 190 |
$uid = (int) $row->uid; |
| 191 |
if ( $uid < 0 ) { |
| 192 |
continue; |
| 193 |
} |
| 194 |
$map[ $uid ] = array( |
| 195 |
'orders' => (int) $row->orders, |
| 196 |
'spend' => (float) $row->spend, |
| 197 |
'first' => (string) $row->first_order, |
| 198 |
'last' => (string) $row->last_order, |
| 199 |
); |
| 200 |
} |
| 201 |
|
| 202 |
/** |
| 203 |
* Filter the per-customer order aggregate the Customers section |
| 204 |
* is built from. |
| 205 |
* |
| 206 |
* Keyed by user id (`0` is the guest aggregate); each value is |
| 207 |
* `array( 'orders' => int, 'spend' => float, 'first' => gmt |
| 208 |
* datetime, 'last' => gmt datetime )`. A store that keeps order |
| 209 |
* money somewhere else — a subscriptions plugin, a marketplace |
| 210 |
* split — can rewrite the whole map here and every band, tile and |
| 211 |
* panel follows. |
| 212 |
* |
| 213 |
* **Status: Experimental** |
| 214 |
* |
| 215 |
* @param array $map Aggregate keyed by user id. |
| 216 |
*/ |
| 217 |
$map = (array) apply_filters( 'openstation_my_wordpress_woo_customer_spend_map', $map ); |
| 218 |
|
| 219 |
set_transient( 'desktop_mode_woo_customer_spend', $map, 5 * MINUTE_IN_SECONDS ); |
| 220 |
$memo = $map; |
| 221 |
|
| 222 |
return $memo; |
| 223 |
} |
| 224 |
|
| 225 |
/** |
| 226 |
* The store's average order value across every paid order. |
| 227 |
* |
| 228 |
* Includes guests: a threshold derived only from account holders would |
| 229 |
* sit wherever the checkout-registration rate happened to put it. |
| 230 |
* |
| 231 |
* @return float |
| 232 |
*/ |
| 233 |
function openstation_my_wordpress_woo_store_aov() { |
| 234 |
$orders = 0; |
| 235 |
$spend = 0.0; |
| 236 |
foreach ( openstation_my_wordpress_woo_customer_spend_map() as $stats ) { |
| 237 |
$orders += (int) $stats['orders']; |
| 238 |
$spend += (float) $stats['spend']; |
| 239 |
} |
| 240 |
|
| 241 |
return $orders > 0 ? $spend / $orders : 0.0; |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* Lifetime spend at or above which a customer is a VIP. |
| 246 |
* |
| 247 |
* Derived rather than fixed: "spent over 500" means nothing without |
| 248 |
* knowing whether the store sells postcards or pianos. Three average |
| 249 |
* orders is the default — enough to be deliberate repeat custom on any |
| 250 |
* store, cheap to compute, and one filter away from a merchant's own |
| 251 |
* number. |
| 252 |
* |
| 253 |
* @return float Threshold, or `0.0` when the store has no paid orders |
| 254 |
* yet (in which case nothing can qualify). |
| 255 |
*/ |
| 256 |
function openstation_my_wordpress_woo_vip_threshold() { |
| 257 |
$aov = openstation_my_wordpress_woo_store_aov(); |
| 258 |
|
| 259 |
/** |
| 260 |
* Filter the lifetime-spend threshold for the VIP band. |
| 261 |
* |
| 262 |
* **Status: Experimental** |
| 263 |
* |
| 264 |
* @param float $threshold Threshold in store currency. |
| 265 |
* @param float $aov The store's average order value. |
| 266 |
*/ |
| 267 |
return (float) apply_filters( |
| 268 |
'openstation_my_wordpress_woo_vip_threshold', |
| 269 |
$aov * 3, |
| 270 |
$aov |
| 271 |
); |
| 272 |
} |
| 273 |
|
| 274 |
/** |
| 275 |
* Days without an order after which a customer counts as lapsed. |
| 276 |
* |
| 277 |
* @return int |
| 278 |
*/ |
| 279 |
function openstation_my_wordpress_woo_customer_lapse_days() { |
| 280 |
/** |
| 281 |
* Filter the lapse window for the Customers section. |
| 282 |
* |
| 283 |
* **Status: Experimental** |
| 284 |
* |
| 285 |
* @param int $days Days since the last order. |
| 286 |
*/ |
| 287 |
$days = (int) apply_filters( |
| 288 |
'openstation_my_wordpress_woo_customer_lapse_days', |
| 289 |
OPENSTATION_WOO_CUSTOMER_LAPSE_DAYS |
| 290 |
); |
| 291 |
|
| 292 |
return max( 1, $days ); |
| 293 |
} |
| 294 |
|
| 295 |
/* |
| 296 |
------------------------------------------------------------------- |
| 297 |
* Bands |
| 298 |
* ---------------------------------------------------------------- |
| 299 |
*/ |
| 300 |
|
| 301 |
/** |
| 302 |
* Band definitions for the Customers section, in display order. |
| 303 |
* |
| 304 |
* VIP and Lapsed lead because they are the two the merchant can do |
| 305 |
* something about — one to look after, one to win back. Everything |
| 306 |
* else is context, and "No orders" trails because a registered |
| 307 |
* account that never bought is the least urgent row on the screen. |
| 308 |
* |
| 309 |
* @return array[] Each entry: `id`, `label`, `order`, optional `tone`. |
| 310 |
*/ |
| 311 |
function openstation_my_wordpress_woo_customer_band_defs() { |
| 312 |
$bands = array( |
| 313 |
array( |
| 314 |
'id' => 'vip', |
| 315 |
'label' => __( 'VIP', 'desktop-mode' ), |
| 316 |
'order' => 10, |
| 317 |
'tone' => 'success', |
| 318 |
), |
| 319 |
array( |
| 320 |
'id' => 'lapsed', |
| 321 |
'label' => __( 'Lapsed', 'desktop-mode' ), |
| 322 |
'order' => 20, |
| 323 |
'tone' => 'warn', |
| 324 |
), |
| 325 |
array( |
| 326 |
'id' => 'repeat', |
| 327 |
'label' => __( 'Repeat', 'desktop-mode' ), |
| 328 |
'order' => 30, |
| 329 |
), |
| 330 |
array( |
| 331 |
'id' => 'new', |
| 332 |
'label' => __( 'New', 'desktop-mode' ), |
| 333 |
'order' => 40, |
| 334 |
), |
| 335 |
array( |
| 336 |
'id' => 'none', |
| 337 |
'label' => __( 'No orders yet', 'desktop-mode' ), |
| 338 |
'order' => 50, |
| 339 |
), |
| 340 |
); |
| 341 |
|
| 342 |
/** |
| 343 |
* Filter the band definitions for the Customers section. |
| 344 |
* |
| 345 |
* Changing a band's membership rule means filtering |
| 346 |
* `openstation_my_wordpress_woo_customer_band` as well — this |
| 347 |
* filter only decides what the bands are called and in which |
| 348 |
* order they render. |
| 349 |
* |
| 350 |
* **Status: Experimental** |
| 351 |
* |
| 352 |
* @param array[] $bands Band descriptors. |
| 353 |
*/ |
| 354 |
return (array) apply_filters( 'openstation_my_wordpress_woo_customer_bands', $bands ); |
| 355 |
} |
| 356 |
|
| 357 |
/** |
| 358 |
* Which band a customer's aggregate puts them in. |
| 359 |
* |
| 360 |
* @param array $stats Aggregate row: `orders`, `spend`, `last`. |
| 361 |
* @return string Band id. |
| 362 |
*/ |
| 363 |
function openstation_my_wordpress_woo_customer_band_id( $stats ) { |
| 364 |
$orders = (int) ( $stats['orders'] ?? 0 ); |
| 365 |
$spend = (float) ( $stats['spend'] ?? 0 ); |
| 366 |
$last = (string) ( $stats['last'] ?? '' ); |
| 367 |
|
| 368 |
$band = 'none'; |
| 369 |
if ( $orders > 0 ) { |
| 370 |
$threshold = openstation_my_wordpress_woo_vip_threshold(); |
| 371 |
$lapsed = openstation_my_wordpress_woo_customer_days_since( $last ); |
| 372 |
|
| 373 |
if ( $threshold > 0 && $spend >= $threshold ) { |
| 374 |
// VIP outranks lapsed on purpose: a big spender who has |
| 375 |
// gone quiet is still the row you want at the top of the |
| 376 |
// screen, and the days-since line in the pane says the |
| 377 |
// rest. |
| 378 |
$band = 'vip'; |
| 379 |
} elseif ( null !== $lapsed && $lapsed > openstation_my_wordpress_woo_customer_lapse_days() ) { |
| 380 |
$band = 'lapsed'; |
| 381 |
} elseif ( $orders > 1 ) { |
| 382 |
$band = 'repeat'; |
| 383 |
} else { |
| 384 |
$band = 'new'; |
| 385 |
} |
| 386 |
} |
| 387 |
|
| 388 |
/** |
| 389 |
* Filter the band a customer lands in. |
| 390 |
* |
| 391 |
* **Status: Experimental** |
| 392 |
* |
| 393 |
* @param string $band Band id. |
| 394 |
* @param array $stats The customer's order aggregate. |
| 395 |
*/ |
| 396 |
return (string) apply_filters( 'openstation_my_wordpress_woo_customer_band', $band, $stats ); |
| 397 |
} |
| 398 |
|
| 399 |
/** |
| 400 |
* Whole days between a GMT datetime and now, or `null` when the input |
| 401 |
* isn't a usable date. |
| 402 |
* |
| 403 |
* @param string $gmt_datetime `Y-m-d H:i:s` in GMT. |
| 404 |
* @return int|null |
| 405 |
*/ |
| 406 |
function openstation_my_wordpress_woo_customer_days_since( $gmt_datetime ) { |
| 407 |
if ( '' === (string) $gmt_datetime ) { |
| 408 |
return null; |
| 409 |
} |
| 410 |
$stamp = strtotime( $gmt_datetime . ' GMT' ); |
| 411 |
if ( ! $stamp ) { |
| 412 |
return null; |
| 413 |
} |
| 414 |
|
| 415 |
return (int) floor( ( time() - $stamp ) / DAY_IN_SECONDS ); |
| 416 |
} |
| 417 |
|
| 418 |
/** |
| 419 |
* Band definitions with an exact row count each, for the client. |
| 420 |
* |
| 421 |
* @return array[] |
| 422 |
*/ |
| 423 |
function openstation_my_wordpress_woo_customer_bands_with_counts() { |
| 424 |
$plan = openstation_my_wordpress_woo_customer_plan(); |
| 425 |
$counts = (array) ( $plan['counts'] ?? array() ); |
| 426 |
|
| 427 |
$bands = array(); |
| 428 |
foreach ( openstation_my_wordpress_woo_customer_band_defs() as $band ) { |
| 429 |
$band['count'] = (int) ( $counts[ $band['id'] ] ?? 0 ); |
| 430 |
$bands[] = $band; |
| 431 |
} |
| 432 |
|
| 433 |
return $bands; |
| 434 |
} |
| 435 |
|
| 436 |
/* |
| 437 |
------------------------------------------------------------------- |
| 438 |
* The ordering plan |
| 439 |
* ---------------------------------------------------------------- |
| 440 |
*/ |
| 441 |
|
| 442 |
/** |
| 443 |
* Candidate customer ids — everyone who has paid for something, plus |
| 444 |
* everyone holding the `customer` role. |
| 445 |
* |
| 446 |
* The union matters in both directions: a shop manager who buys from |
| 447 |
* their own store is a customer, and a checkout-registered account |
| 448 |
* that hasn't ordered yet is a customer the merchant would want to |
| 449 |
* see. Neither query alone finds both. |
| 450 |
* |
| 451 |
* @return int[] User ids, unordered. |
| 452 |
*/ |
| 453 |
function openstation_my_wordpress_woo_customer_candidate_ids() { |
| 454 |
$ids = array(); |
| 455 |
foreach ( openstation_my_wordpress_woo_customer_spend_map() as $user_id => $stats ) { |
| 456 |
$user_id = (int) $user_id; |
| 457 |
if ( $user_id > 0 ) { |
| 458 |
$ids[ $user_id ] = true; |
| 459 |
} |
| 460 |
} |
| 461 |
|
| 462 |
$role_users = get_users( |
| 463 |
array( |
| 464 |
'role' => 'customer', |
| 465 |
'fields' => 'ID', |
| 466 |
'number' => OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS + 1, |
| 467 |
'orderby' => 'registered', |
| 468 |
'order' => 'DESC', |
| 469 |
) |
| 470 |
); |
| 471 |
foreach ( (array) $role_users as $user_id ) { |
| 472 |
$ids[ (int) $user_id ] = true; |
| 473 |
} |
| 474 |
|
| 475 |
/** |
| 476 |
* Filter the set of users the Customers section considers. |
| 477 |
* |
| 478 |
* **Status: Experimental** |
| 479 |
* |
| 480 |
* @param int[] $ids Candidate user ids. |
| 481 |
*/ |
| 482 |
return array_values( |
| 483 |
array_map( 'intval', array_keys( (array) apply_filters( 'openstation_my_wordpress_woo_customer_ids', $ids ) ) ) |
| 484 |
); |
| 485 |
} |
| 486 |
|
| 487 |
/** |
| 488 |
* The band-ordered customer id list plus an exact count per band. |
| 489 |
* |
| 490 |
* Same contract as the catalogue's plan, and for the same reason: |
| 491 |
* bands only stop reshuffling if rows *arrive* in band order. A band |
| 492 |
* that fills late expands above whatever the user is already reading. |
| 493 |
* |
| 494 |
* Cached for five minutes and flushed on any order change. |
| 495 |
* |
| 496 |
* @return array{ids:int[], counts:array<string,int>, capped:bool, customers:int} |
| 497 |
*/ |
| 498 |
function openstation_my_wordpress_woo_customer_plan() { |
| 499 |
static $memo = null; |
| 500 |
if ( null !== $memo ) { |
| 501 |
return $memo; |
| 502 |
} |
| 503 |
|
| 504 |
$cached = get_transient( 'desktop_mode_woo_customer_plan' ); |
| 505 |
if ( is_array( $cached ) && isset( $cached['ids'], $cached['counts'] ) ) { |
| 506 |
$memo = $cached; |
| 507 |
return $memo; |
| 508 |
} |
| 509 |
|
| 510 |
$candidates = openstation_my_wordpress_woo_customer_candidate_ids(); |
| 511 |
$total = count( $candidates ); |
| 512 |
|
| 513 |
if ( $total > OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS ) { |
| 514 |
$memo = array( |
| 515 |
'ids' => array(), |
| 516 |
'counts' => array(), |
| 517 |
'capped' => true, |
| 518 |
'customers' => $total, |
| 519 |
); |
| 520 |
set_transient( 'desktop_mode_woo_customer_plan', $memo, 5 * MINUTE_IN_SECONDS ); |
| 521 |
return $memo; |
| 522 |
} |
| 523 |
|
| 524 |
$map = openstation_my_wordpress_woo_customer_spend_map(); |
| 525 |
$buckets = array(); |
| 526 |
$counts = array(); |
| 527 |
foreach ( openstation_my_wordpress_woo_customer_band_defs() as $band ) { |
| 528 |
$buckets[ $band['id'] ] = array(); |
| 529 |
$counts[ $band['id'] ] = 0; |
| 530 |
} |
| 531 |
|
| 532 |
foreach ( $candidates as $user_id ) { |
| 533 |
$stats = $map[ $user_id ] ?? array( |
| 534 |
'orders' => 0, |
| 535 |
'spend' => 0.0, |
| 536 |
'first' => '', |
| 537 |
'last' => '', |
| 538 |
); |
| 539 |
$band = openstation_my_wordpress_woo_customer_band_id( $stats ); |
| 540 |
if ( ! isset( $buckets[ $band ] ) ) { |
| 541 |
// A filter invented a band the definitions don't declare. |
| 542 |
// Park it under "no orders" rather than dropping the row: |
| 543 |
// a customer missing from the list is a worse outcome than |
| 544 |
// one in an unexpected group. |
| 545 |
$band = 'none'; |
| 546 |
if ( ! isset( $buckets[ $band ] ) ) { |
| 547 |
continue; |
| 548 |
} |
| 549 |
} |
| 550 |
// Highest spend first inside every band — the ordering the |
| 551 |
// merchant would apply by hand. |
| 552 |
$buckets[ $band ][] = array( |
| 553 |
'id' => $user_id, |
| 554 |
'spend' => (float) $stats['spend'], |
| 555 |
); |
| 556 |
++$counts[ $band ]; |
| 557 |
} |
| 558 |
|
| 559 |
$ordered = array(); |
| 560 |
foreach ( $buckets as $rows ) { |
| 561 |
usort( |
| 562 |
$rows, |
| 563 |
static function ( $a, $b ) { |
| 564 |
if ( $a['spend'] === $b['spend'] ) { |
| 565 |
return $a['id'] <=> $b['id']; |
| 566 |
} |
| 567 |
return $b['spend'] <=> $a['spend']; |
| 568 |
} |
| 569 |
); |
| 570 |
foreach ( $rows as $row ) { |
| 571 |
$ordered[] = (int) $row['id']; |
| 572 |
} |
| 573 |
} |
| 574 |
|
| 575 |
$memo = array( |
| 576 |
'ids' => $ordered, |
| 577 |
'counts' => $counts, |
| 578 |
'capped' => false, |
| 579 |
'customers' => $total, |
| 580 |
); |
| 581 |
set_transient( 'desktop_mode_woo_customer_plan', $memo, 5 * MINUTE_IN_SECONDS ); |
| 582 |
|
| 583 |
return $memo; |
| 584 |
} |
| 585 |
|
| 586 |
/** |
| 587 |
* A readable summary of whether the Customers list is band-ordered, |
| 588 |
* for diagnosing a section whose bands look wrong. Mirrors the |
| 589 |
* catalogue's `ordering` blob. |
| 590 |
* |
| 591 |
* @return array{mode:string, customers:int, ordered:int, limit:int} |
| 592 |
*/ |
| 593 |
function openstation_my_wordpress_woo_customer_ordering_state() { |
| 594 |
$plan = openstation_my_wordpress_woo_customer_plan(); |
| 595 |
|
| 596 |
return array( |
| 597 |
'mode' => ! empty( $plan['capped'] ) ? 'capped' : 'ordered', |
| 598 |
'customers' => (int) ( $plan['customers'] ?? 0 ), |
| 599 |
'ordered' => count( (array) ( $plan['ids'] ?? array() ) ), |
| 600 |
'limit' => OPENSTATION_WOO_MAX_ORDERED_CUSTOMERS, |
| 601 |
); |
| 602 |
} |
| 603 |
|
| 604 |
/** |
| 605 |
* Drop the cached aggregate and plan when an order changes, so a |
| 606 |
* first-time buyer moves out of "No orders yet" on the next load |
| 607 |
* rather than five minutes later. |
| 608 |
* |
| 609 |
* @return void |
| 610 |
*/ |
| 611 |
function openstation_my_wordpress_woo_flush_customer_caches() { |
| 612 |
delete_transient( 'desktop_mode_woo_customer_spend' ); |
| 613 |
delete_transient( 'desktop_mode_woo_customer_plan' ); |
| 614 |
} |
| 615 |
add_action( 'woocommerce_new_order', 'openstation_my_wordpress_woo_flush_customer_caches' ); |
| 616 |
add_action( 'woocommerce_update_order', 'openstation_my_wordpress_woo_flush_customer_caches' ); |
| 617 |
add_action( 'woocommerce_order_status_changed', 'openstation_my_wordpress_woo_flush_customer_caches' ); |
| 618 |
add_action( 'woocommerce_delete_order', 'openstation_my_wordpress_woo_flush_customer_caches' ); |
| 619 |
add_action( 'woocommerce_trash_order', 'openstation_my_wordpress_woo_flush_customer_caches' ); |
| 620 |
// Untrash is its own event, not an update: restoring a paid order has |
| 621 |
// to move the buyer's spend and band back where they were, and nothing |
| 622 |
// else fires when it happens. |
| 623 |
add_action( 'woocommerce_untrash_order', 'openstation_my_wordpress_woo_flush_customer_caches' ); |
| 624 |
|
| 625 |
/* |
| 626 |
------------------------------------------------------------------- |
| 627 |
* Per-customer facts |
| 628 |
* ---------------------------------------------------------------- |
| 629 |
*/ |
| 630 |
|
| 631 |
/** |
| 632 |
* The `openstation_woo_customer` payload for one user — everything a |
| 633 |
* tile, a band, and the compact pane row need, and nothing that costs |
| 634 |
* an extra query. |
| 635 |
* |
| 636 |
* Every field here is read from the cached aggregate. The deeper |
| 637 |
* facts (last order number, favourite product, billing address) live |
| 638 |
* in the customer *summary* below, which only runs for the one row |
| 639 |
* actually selected. |
| 640 |
* |
| 641 |
* @param int $user_id User id. |
| 642 |
* @return array |
| 643 |
*/ |
| 644 |
function openstation_my_wordpress_woo_customer_facts( $user_id ) { |
| 645 |
$user_id = (int) $user_id; |
| 646 |
$map = openstation_my_wordpress_woo_customer_spend_map(); |
| 647 |
$stats = $map[ $user_id ] ?? array( |
| 648 |
'orders' => 0, |
| 649 |
'spend' => 0.0, |
| 650 |
'first' => '', |
| 651 |
'last' => '', |
| 652 |
); |
| 653 |
|
| 654 |
$orders = (int) $stats['orders']; |
| 655 |
$spend = (float) $stats['spend']; |
| 656 |
$days = openstation_my_wordpress_woo_customer_days_since( $stats['last'] ); |
| 657 |
|
| 658 |
$facts = array( |
| 659 |
'band' => openstation_my_wordpress_woo_customer_band_id( $stats ), |
| 660 |
'orders' => $orders, |
| 661 |
'spend' => openstation_my_wordpress_woo_price( $spend ), |
| 662 |
// Raw alongside the formatted string: the client sorts and |
| 663 |
// compares on this, and no locale can break a float. |
| 664 |
'spendRaw' => round( $spend, 2 ), |
| 665 |
'aov' => $orders > 0 ? openstation_my_wordpress_woo_price( $spend / $orders ) : '', |
| 666 |
'firstOrder' => '' !== $stats['first'] ? mysql2date( 'c', $stats['first'], false ) : '', |
| 667 |
'lastOrder' => '' !== $stats['last'] ? mysql2date( 'c', $stats['last'], false ) : '', |
| 668 |
'daysSince' => $days, |
| 669 |
// The list screen filtered to this person. On the row rather |
| 670 |
// than only in the summary so the tile's context menu can open |
| 671 |
// it without first fetching a panel the user never asked for. |
| 672 |
'ordersUrl' => $orders > 0 ? openstation_my_wordpress_woo_customer_orders_url( $user_id ) : '', |
| 673 |
); |
| 674 |
|
| 675 |
/** |
| 676 |
* Filter the compact customer facts carried on every row of the |
| 677 |
* Customers section (and on `/wp/v2/users` rows). |
| 678 |
* |
| 679 |
* **Status: Experimental** |
| 680 |
* |
| 681 |
* @param array $facts Fact payload. |
| 682 |
* @param int $user_id The customer. |
| 683 |
*/ |
| 684 |
return (array) apply_filters( 'openstation_my_wordpress_woo_customer_facts', $facts, $user_id ); |
| 685 |
} |
| 686 |
|
| 687 |
/** |
| 688 |
* Register `openstation_woo_customer` on the core `user` resource. |
| 689 |
* |
| 690 |
* Deliberately not limited to our own collection: it means the |
| 691 |
* built-in Users section, and any plugin reading `/wp/v2/users`, gets |
| 692 |
* lifetime spend for free. The field is gated the same way the rest of |
| 693 |
* the section is — a viewer who can't see orders sees no money. |
| 694 |
* |
| 695 |
* @return void |
| 696 |
*/ |
| 697 |
function openstation_my_wordpress_woo_register_customer_field() { |
| 698 |
if ( ! openstation_my_wordpress_woo_active() ) { |
| 699 |
return; |
| 700 |
} |
| 701 |
|
| 702 |
register_rest_field( |
| 703 |
'user', |
| 704 |
'openstation_woo_customer', |
| 705 |
array( |
| 706 |
'get_callback' => static function ( $user ) { |
| 707 |
if ( true !== openstation_my_wordpress_woo_customers_permission() ) { |
| 708 |
return null; |
| 709 |
} |
| 710 |
$id = isset( $user['id'] ) ? (int) $user['id'] : 0; |
| 711 |
return $id > 0 ? openstation_my_wordpress_woo_customer_facts( $id ) : null; |
| 712 |
}, |
| 713 |
'schema' => array( |
| 714 |
'description' => __( 'WooCommerce lifetime facts for this customer.', 'desktop-mode' ), |
| 715 |
'type' => array( 'object', 'null' ), |
| 716 |
'context' => array( 'view', 'edit', 'embed' ), |
| 717 |
), |
| 718 |
) |
| 719 |
); |
| 720 |
} |
| 721 |
add_action( 'rest_api_init', 'openstation_my_wordpress_woo_register_customer_field' ); |
| 722 |
|
| 723 |
/* |
| 724 |
------------------------------------------------------------------- |
| 725 |
* REST |
| 726 |
* ---------------------------------------------------------------- |
| 727 |
*/ |
| 728 |
|
| 729 |
/** |
| 730 |
* Whether the current user may see customer money. |
| 731 |
* |
| 732 |
* Two gates, both required: order access (the data *is* order data) |
| 733 |
* and `list_users` (the rows are people). An editor who can moderate |
| 734 |
* comments has neither. |
| 735 |
* |
| 736 |
* @return true|WP_Error |
| 737 |
*/ |
| 738 |
function openstation_my_wordpress_woo_customers_permission() { |
| 739 |
$orders = openstation_my_wordpress_woo_orders_permission(); |
| 740 |
if ( is_wp_error( $orders ) ) { |
| 741 |
return $orders; |
| 742 |
} |
| 743 |
|
| 744 |
if ( ! current_user_can( 'list_users' ) ) { |
| 745 |
return new WP_Error( |
| 746 |
'openstation_woo_forbidden', |
| 747 |
__( 'Sorry, you are not allowed to view customers.', 'desktop-mode' ), |
| 748 |
array( 'status' => rest_authorization_required_code() ) |
| 749 |
); |
| 750 |
} |
| 751 |
|
| 752 |
return true; |
| 753 |
} |
| 754 |
|
| 755 |
/** |
| 756 |
* Shape a user as the row the site window's `user` entity kind reads — |
| 757 |
* the same field set `/wp/v2/users` returns, plus our two payloads. |
| 758 |
* |
| 759 |
* @param WP_User $user User. |
| 760 |
* @return array |
| 761 |
*/ |
| 762 |
function openstation_my_wordpress_woo_customer_row( $user ) { |
| 763 |
$avatars = array(); |
| 764 |
foreach ( rest_get_avatar_sizes() as $size ) { |
| 765 |
$avatars[ (string) $size ] = get_avatar_url( $user->ID, array( 'size' => $size ) ); |
| 766 |
} |
| 767 |
|
| 768 |
return array( |
| 769 |
'id' => (int) $user->ID, |
| 770 |
'name' => $user->display_name, |
| 771 |
'slug' => $user->user_nicename, |
| 772 |
'description' => (string) get_user_meta( $user->ID, 'description', true ), |
| 773 |
'link' => (string) get_author_posts_url( $user->ID ), |
| 774 |
'avatar_urls' => $avatars, |
| 775 |
'openstation_summary' => function_exists( 'openstation_my_wordpress_user_summary_payload' ) |
| 776 |
? openstation_my_wordpress_user_summary_payload( $user->ID ) |
| 777 |
: array(), |
| 778 |
'openstation_woo_customer' => openstation_my_wordpress_woo_customer_facts( $user->ID ), |
| 779 |
); |
| 780 |
} |
| 781 |
|
| 782 |
/** |
| 783 |
* `GET /woocommerce/customers` — paginated, user-shaped customer list. |
| 784 |
* |
| 785 |
* @param WP_REST_Request $request Request. |
| 786 |
* @return WP_REST_Response |
| 787 |
*/ |
| 788 |
function openstation_my_wordpress_woo_customers( $request ) { |
| 789 |
$per_page = max( 1, min( 100, (int) ( $request['per_page'] ?? 24 ) ) ); |
| 790 |
$page = max( 1, (int) ( $request['page'] ?? 1 ) ); |
| 791 |
$search = trim( (string) ( $request['search'] ?? '' ) ); |
| 792 |
|
| 793 |
$plan = openstation_my_wordpress_woo_customer_plan(); |
| 794 |
$capped = ! empty( $plan['capped'] ); |
| 795 |
|
| 796 |
$args = array( |
| 797 |
'number' => $per_page, |
| 798 |
'paged' => $page, |
| 799 |
'fields' => 'all', |
| 800 |
); |
| 801 |
|
| 802 |
if ( $capped ) { |
| 803 |
// Past the cap the plan holds no ids, so hand the ordering |
| 804 |
// back to the database: newest accounts first, which is the |
| 805 |
// only useful order left once bands are off. |
| 806 |
$args['role'] = 'customer'; |
| 807 |
$args['orderby'] = 'registered'; |
| 808 |
$args['order'] = 'DESC'; |
| 809 |
} else { |
| 810 |
$ids = (array) $plan['ids']; |
| 811 |
if ( empty( $ids ) ) { |
| 812 |
$response = rest_ensure_response( array() ); |
| 813 |
$response->header( 'X-WP-Total', '0' ); |
| 814 |
$response->header( 'X-WP-TotalPages', '1' ); |
| 815 |
return $response; |
| 816 |
} |
| 817 |
$args['include'] = $ids; |
| 818 |
// `include` + `orderby => include` replays the plan's order |
| 819 |
// verbatim, the same trick the catalogue uses with `post__in`. |
| 820 |
$args['orderby'] = 'include'; |
| 821 |
} |
| 822 |
|
| 823 |
if ( '' !== $search ) { |
| 824 |
$args['search'] = '*' . $search . '*'; |
| 825 |
$args['search_columns'] = array( 'user_login', 'user_email', 'user_nicename', 'display_name' ); |
| 826 |
// A search is a different question from "show me the roster", |
| 827 |
// and the plan's order would hide matches below the fold. |
| 828 |
// Ordering falls back to relevance-free display name, which is |
| 829 |
// at least stable. |
| 830 |
if ( ! $capped ) { |
| 831 |
$args['orderby'] = 'display_name'; |
| 832 |
$args['order'] = 'ASC'; |
| 833 |
} |
| 834 |
} |
| 835 |
|
| 836 |
/** |
| 837 |
* Filter the `WP_User_Query` args for the Customers section. |
| 838 |
* |
| 839 |
* `number` and `paged` are set by the paginator and will be |
| 840 |
* overwritten. |
| 841 |
* |
| 842 |
* **Status: Experimental** |
| 843 |
* |
| 844 |
* @param array $args Query args. |
| 845 |
* @param WP_REST_Request $request The request. |
| 846 |
*/ |
| 847 |
$args = (array) apply_filters( 'openstation_my_wordpress_woo_customer_query_args', $args, $request ); |
| 848 |
|
| 849 |
$query = new WP_User_Query( $args ); |
| 850 |
$total = (int) $query->get_total(); |
| 851 |
$pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 1; |
| 852 |
|
| 853 |
$users = array(); |
| 854 |
foreach ( (array) $query->get_results() as $user ) { |
| 855 |
if ( $user instanceof WP_User ) { |
| 856 |
$users[] = $user; |
| 857 |
} |
| 858 |
} |
| 859 |
|
| 860 |
// The customer *facts* come from one cached aggregate, but the |
| 861 |
// generic user summary on each row is two indexed queries a piece |
| 862 |
// — 200 of them on a full page. Prefetch the page in two grouped |
| 863 |
// queries and every row below answers from memory. |
| 864 |
if ( function_exists( 'openstation_my_wordpress_user_summary_prime' ) ) { |
| 865 |
openstation_my_wordpress_user_summary_prime( |
| 866 |
array_map( |
| 867 |
static function ( $user ) { |
| 868 |
return (int) $user->ID; |
| 869 |
}, |
| 870 |
$users |
| 871 |
) |
| 872 |
); |
| 873 |
} |
| 874 |
|
| 875 |
$rows = array(); |
| 876 |
foreach ( $users as $user ) { |
| 877 |
$rows[] = openstation_my_wordpress_woo_customer_row( $user ); |
| 878 |
} |
| 879 |
|
| 880 |
$response = rest_ensure_response( $rows ); |
| 881 |
$response->header( 'X-WP-Total', (string) $total ); |
| 882 |
$response->header( 'X-WP-TotalPages', (string) max( 1, $pages ) ); |
| 883 |
// Same diagnostic contract the Orders route carries: an empty |
| 884 |
// folder with a confident count should be answerable from the |
| 885 |
// network tab, not guessed at. |
| 886 |
$response->header( 'X-Desktop-Mode-Woo-Customers-Mode', $capped ? 'capped' : 'ordered' ); |
| 887 |
|
| 888 |
return $response; |
| 889 |
} |
| 890 |
|
| 891 |
/** |
| 892 |
* `GET /woocommerce/customers/<id>` — one user-shaped customer. |
| 893 |
* |
| 894 |
* @param WP_REST_Request $request Request. |
| 895 |
* @return WP_REST_Response|WP_Error |
| 896 |
*/ |
| 897 |
function openstation_my_wordpress_woo_customer( $request ) { |
| 898 |
$user = get_userdata( (int) $request['id'] ); |
| 899 |
if ( ! $user instanceof WP_User ) { |
| 900 |
return new WP_Error( |
| 901 |
'openstation_woo_no_customer', |
| 902 |
__( 'Customer not found.', 'desktop-mode' ), |
| 903 |
array( 'status' => 404 ) |
| 904 |
); |
| 905 |
} |
| 906 |
|
| 907 |
return rest_ensure_response( openstation_my_wordpress_woo_customer_row( $user ) ); |
| 908 |
} |
| 909 |
|
| 910 |
/* |
| 911 |
------------------------------------------------------------------- |
| 912 |
* The customer summary — the right pane |
| 913 |
* ---------------------------------------------------------------- |
| 914 |
*/ |
| 915 |
|
| 916 |
/** |
| 917 |
* The customer's most-bought product, resolved from their recent |
| 918 |
* orders. |
| 919 |
* |
| 920 |
* Bounded to the last 50 orders: this runs once per selection, and a |
| 921 |
* decade of order history would turn a preview pane into a page load. |
| 922 |
* |
| 923 |
* @param int $user_id User id. |
| 924 |
* @return array{label:string, editUrl:string, quantity:int}|null |
| 925 |
*/ |
| 926 |
function openstation_my_wordpress_woo_customer_favourite( $user_id ) { |
| 927 |
$orders = wc_get_orders( |
| 928 |
array( |
| 929 |
'customer_id' => (int) $user_id, |
| 930 |
'limit' => 50, |
| 931 |
'status' => openstation_my_wordpress_woo_paid_statuses(), |
| 932 |
'orderby' => 'date', |
| 933 |
'order' => 'DESC', |
| 934 |
'return' => 'objects', |
| 935 |
) |
| 936 |
); |
| 937 |
|
| 938 |
$tally = array(); |
| 939 |
foreach ( (array) $orders as $maybe_order ) { |
| 940 |
$order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order; |
| 941 |
if ( ! $order instanceof WC_Abstract_Order ) { |
| 942 |
continue; |
| 943 |
} |
| 944 |
foreach ( $order->get_items() as $item ) { |
| 945 |
$product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0; |
| 946 |
if ( $product_id <= 0 ) { |
| 947 |
continue; |
| 948 |
} |
| 949 |
if ( ! isset( $tally[ $product_id ] ) ) { |
| 950 |
$tally[ $product_id ] = array( |
| 951 |
'label' => $item->get_name(), |
| 952 |
'quantity' => 0, |
| 953 |
); |
| 954 |
} |
| 955 |
$tally[ $product_id ]['quantity'] += (int) $item->get_quantity(); |
| 956 |
} |
| 957 |
} |
| 958 |
|
| 959 |
if ( empty( $tally ) ) { |
| 960 |
return null; |
| 961 |
} |
| 962 |
|
| 963 |
uasort( |
| 964 |
$tally, |
| 965 |
static function ( $a, $b ) { |
| 966 |
return $b['quantity'] <=> $a['quantity']; |
| 967 |
} |
| 968 |
); |
| 969 |
|
| 970 |
$product_id = (int) array_key_first( $tally ); |
| 971 |
$top = $tally[ $product_id ]; |
| 972 |
|
| 973 |
// The link is gated, the fact isn't: someone who may read customer |
| 974 |
// money but not edit products should still be told what that |
| 975 |
// person buys — the name just stops being clickable. |
| 976 |
// `get_edit_post_link()` returns null without `edit_post`, and the |
| 977 |
// cast turns that into the empty string the client reads as "no |
| 978 |
// link"; the explicit check states the intent rather than leaving |
| 979 |
// it resting on a core side-effect. |
| 980 |
$can_edit = get_post( $product_id ) && current_user_can( 'edit_post', $product_id ); |
| 981 |
|
| 982 |
return array( |
| 983 |
'label' => (string) $top['label'], |
| 984 |
'quantity' => (int) $top['quantity'], |
| 985 |
'editUrl' => $can_edit ? (string) get_edit_post_link( $product_id, 'raw' ) : '', |
| 986 |
); |
| 987 |
} |
| 988 |
|
| 989 |
/** |
| 990 |
* A customer's most recent orders, shaped for a list. |
| 991 |
* |
| 992 |
* Bounded and only fetched for the one customer being looked at — the |
| 993 |
* list rows never touch this. |
| 994 |
* |
| 995 |
* @param int $user_id User id. |
| 996 |
* @param int $limit How many. |
| 997 |
* @return array[] |
| 998 |
*/ |
| 999 |
function openstation_my_wordpress_woo_customer_recent_orders( $user_id, $limit = 8 ) { |
| 1000 |
$orders = wc_get_orders( |
| 1001 |
array( |
| 1002 |
'customer_id' => (int) $user_id, |
| 1003 |
'limit' => max( 1, (int) $limit ), |
| 1004 |
'orderby' => 'date', |
| 1005 |
'order' => 'DESC', |
| 1006 |
'return' => 'objects', |
| 1007 |
) |
| 1008 |
); |
| 1009 |
|
| 1010 |
$statuses = wc_get_order_statuses(); |
| 1011 |
$rows = array(); |
| 1012 |
foreach ( (array) $orders as $maybe_order ) { |
| 1013 |
$order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order; |
| 1014 |
if ( ! $order instanceof WC_Abstract_Order ) { |
| 1015 |
continue; |
| 1016 |
} |
| 1017 |
$rows[] = array( |
| 1018 |
'id' => (int) $order->get_id(), |
| 1019 |
'number' => (string) $order->get_order_number(), |
| 1020 |
'status' => (string) $order->get_status(), |
| 1021 |
'statusLabel' => (string) ( $statuses[ 'wc-' . $order->get_status() ] ?? $order->get_status() ), |
| 1022 |
'date' => $order->get_date_created() |
| 1023 |
? $order->get_date_created()->date( 'c' ) |
| 1024 |
: '', |
| 1025 |
'total' => openstation_my_wordpress_woo_price( $order->get_total(), $order->get_currency() ), |
| 1026 |
'items' => (int) $order->get_item_count(), |
| 1027 |
'editUrl' => method_exists( $order, 'get_edit_order_url' ) |
| 1028 |
? (string) $order->get_edit_order_url() |
| 1029 |
: '', |
| 1030 |
); |
| 1031 |
} |
| 1032 |
|
| 1033 |
return $rows; |
| 1034 |
} |
| 1035 |
|
| 1036 |
/** |
| 1037 |
* Merchant facts for one customer — the right-pane panel. |
| 1038 |
* |
| 1039 |
* @param int $id User id. |
| 1040 |
* @return array|WP_Error |
| 1041 |
*/ |
| 1042 |
function openstation_my_wordpress_woo_customer_summary( $id ) { |
| 1043 |
$user = get_userdata( (int) $id ); |
| 1044 |
if ( ! $user instanceof WP_User ) { |
| 1045 |
return new WP_Error( |
| 1046 |
'openstation_woo_no_customer', |
| 1047 |
__( 'Customer not found.', 'desktop-mode' ), |
| 1048 |
array( 'status' => 404 ) |
| 1049 |
); |
| 1050 |
} |
| 1051 |
|
| 1052 |
$facts = openstation_my_wordpress_woo_customer_facts( $user->ID ); |
| 1053 |
$bands = array(); |
| 1054 |
foreach ( openstation_my_wordpress_woo_customer_band_defs() as $band ) { |
| 1055 |
$bands[ $band['id'] ] = (string) $band['label']; |
| 1056 |
} |
| 1057 |
|
| 1058 |
// The most recent order, for the "last bought" line and the jump |
| 1059 |
// into it. One query, only for the selected row. |
| 1060 |
$recent = wc_get_orders( |
| 1061 |
array( |
| 1062 |
'customer_id' => $user->ID, |
| 1063 |
'limit' => 1, |
| 1064 |
'orderby' => 'date', |
| 1065 |
'order' => 'DESC', |
| 1066 |
'return' => 'objects', |
| 1067 |
) |
| 1068 |
); |
| 1069 |
$last = null; |
| 1070 |
foreach ( (array) $recent as $maybe_order ) { |
| 1071 |
$order = is_scalar( $maybe_order ) ? wc_get_order( (int) $maybe_order ) : $maybe_order; |
| 1072 |
if ( $order instanceof WC_Abstract_Order ) { |
| 1073 |
$last = $order; |
| 1074 |
break; |
| 1075 |
} |
| 1076 |
} |
| 1077 |
|
| 1078 |
$customer = null; |
| 1079 |
if ( class_exists( 'WC_Customer' ) ) { |
| 1080 |
try { |
| 1081 |
$customer = new WC_Customer( $user->ID ); |
| 1082 |
} catch ( Exception $e ) { |
| 1083 |
$customer = null; |
| 1084 |
} |
| 1085 |
} |
| 1086 |
|
| 1087 |
$location = ''; |
| 1088 |
$billing = ''; |
| 1089 |
$shipping = ''; |
| 1090 |
$phone = ''; |
| 1091 |
if ( $customer ) { |
| 1092 |
$parts = array_filter( |
| 1093 |
array( |
| 1094 |
$customer->get_billing_city(), |
| 1095 |
$customer->get_billing_country(), |
| 1096 |
) |
| 1097 |
); |
| 1098 |
$location = implode( ', ', $parts ); |
| 1099 |
$phone = (string) $customer->get_billing_phone(); |
| 1100 |
|
| 1101 |
// `WC_Customer` has no formatted-address accessor of its own, |
| 1102 |
// so build the lines the way WooCommerce's order screen does. |
| 1103 |
$format = static function ( array $address ) { |
| 1104 |
if ( ! function_exists( 'WC' ) || ! WC()->countries ) { |
| 1105 |
return ''; |
| 1106 |
} |
| 1107 |
return trim( |
| 1108 |
wp_strip_all_tags( |
| 1109 |
(string) WC()->countries->get_formatted_address( $address, ', ' ) |
| 1110 |
) |
| 1111 |
); |
| 1112 |
}; |
| 1113 |
$billing = $format( |
| 1114 |
array( |
| 1115 |
'address_1' => $customer->get_billing_address_1(), |
| 1116 |
'address_2' => $customer->get_billing_address_2(), |
| 1117 |
'city' => $customer->get_billing_city(), |
| 1118 |
'state' => $customer->get_billing_state(), |
| 1119 |
'postcode' => $customer->get_billing_postcode(), |
| 1120 |
'country' => $customer->get_billing_country(), |
| 1121 |
) |
| 1122 |
); |
| 1123 |
$shipping = $format( |
| 1124 |
array( |
| 1125 |
'address_1' => $customer->get_shipping_address_1(), |
| 1126 |
'address_2' => $customer->get_shipping_address_2(), |
| 1127 |
'city' => $customer->get_shipping_city(), |
| 1128 |
'state' => $customer->get_shipping_state(), |
| 1129 |
'postcode' => $customer->get_shipping_postcode(), |
| 1130 |
'country' => $customer->get_shipping_country(), |
| 1131 |
) |
| 1132 |
); |
| 1133 |
} |
| 1134 |
|
| 1135 |
return array( |
| 1136 |
'type' => 'customer', |
| 1137 |
'id' => (int) $user->ID, |
| 1138 |
'name' => $user->display_name, |
| 1139 |
'username' => $user->user_login, |
| 1140 |
'avatar' => (string) get_avatar_url( $user->ID, array( 'size' => 96 ) ), |
| 1141 |
'email' => $user->user_email, |
| 1142 |
'phone' => $phone, |
| 1143 |
'billing' => $billing, |
| 1144 |
'shipping' => $shipping, |
| 1145 |
// Only the window asks for these; the preview pane's panel |
| 1146 |
// ignores them. One payload, two consumers — cheaper than a |
| 1147 |
// second route, and the window is where the depth belongs. |
| 1148 |
'recentOrders' => openstation_my_wordpress_woo_customer_recent_orders( $user->ID ), |
| 1149 |
'spendRaw' => (float) $facts['spendRaw'], |
| 1150 |
'band' => (string) $facts['band'], |
| 1151 |
'bandLabel' => $bands[ $facts['band'] ] ?? (string) $facts['band'], |
| 1152 |
'orders' => (int) $facts['orders'], |
| 1153 |
'spend' => (string) $facts['spend'], |
| 1154 |
'aov' => (string) $facts['aov'], |
| 1155 |
'firstOrder' => (string) $facts['firstOrder'], |
| 1156 |
'lastOrder' => (string) $facts['lastOrder'], |
| 1157 |
'daysSince' => $facts['daysSince'], |
| 1158 |
'lastOrderNo' => $last ? (string) $last->get_order_number() : '', |
| 1159 |
'lastOrderUrl' => $last && method_exists( $last, 'get_edit_order_url' ) |
| 1160 |
? (string) $last->get_edit_order_url() |
| 1161 |
: '', |
| 1162 |
'lastOrderTotal' => $last |
| 1163 |
? openstation_my_wordpress_woo_price( $last->get_total(), $last->get_currency() ) |
| 1164 |
: '', |
| 1165 |
'favourite' => openstation_my_wordpress_woo_customer_favourite( $user->ID ), |
| 1166 |
'location' => $location, |
| 1167 |
'registered' => '' !== $user->user_registered |
| 1168 |
? mysql2date( 'c', $user->user_registered, false ) |
| 1169 |
: '', |
| 1170 |
'ordersUrl' => openstation_my_wordpress_woo_customer_orders_url( $user->ID ), |
| 1171 |
'profileUrl' => current_user_can( 'edit_user', $user->ID ) |
| 1172 |
? (string) get_edit_user_link( $user->ID ) |
| 1173 |
: '', |
| 1174 |
); |
| 1175 |
} |
| 1176 |
|
| 1177 |
/** |
| 1178 |
* The admin URL listing this customer's orders — HPOS and legacy |
| 1179 |
* storage put that screen in different places. |
| 1180 |
* |
| 1181 |
* @param int $user_id User id. |
| 1182 |
* @return string |
| 1183 |
*/ |
| 1184 |
function openstation_my_wordpress_woo_customer_orders_url( $user_id ) { |
| 1185 |
$user_id = (int) $user_id; |
| 1186 |
|
| 1187 |
if ( openstation_my_wordpress_woo_hpos_enabled() ) { |
| 1188 |
return admin_url( 'admin.php?page=wc-orders&_customer_user=' . $user_id ); |
| 1189 |
} |
| 1190 |
|
| 1191 |
return admin_url( 'edit.php?post_type=shop_order&_customer_user=' . $user_id ); |
| 1192 |
} |
| 1193 |
|
| 1194 |
/** |
| 1195 |
* Add the `customer` type to the shared summary route. |
| 1196 |
* |
| 1197 |
* Joining through the route's own extension seam rather than editing |
| 1198 |
* its switch keeps the whole Customers surface in one file — and |
| 1199 |
* proves the seam works, since this is the first thing to use it. |
| 1200 |
* |
| 1201 |
* @param array|null $data Summary payload (untouched for other types). |
| 1202 |
* @param string $type Summary type. |
| 1203 |
* @param int $id Object id. |
| 1204 |
* @return array|WP_Error|null |
| 1205 |
*/ |
| 1206 |
function openstation_my_wordpress_woo_customer_summary_filter( $data, $type, $id ) { |
| 1207 |
if ( 'customer' !== $type ) { |
| 1208 |
return $data; |
| 1209 |
} |
| 1210 |
|
| 1211 |
return openstation_my_wordpress_woo_customer_summary( $id ); |
| 1212 |
} |
| 1213 |
|
| 1214 |
/** |
| 1215 |
* Gate the `customer` summary type. The generic fallback checks |
| 1216 |
* `edit_post` against the id, which for a user id is meaningless — |
| 1217 |
* and, on a site where post and user ids collide, wrong. |
| 1218 |
* |
| 1219 |
* @param true|WP_Error|null $allowed Permission verdict so far. |
| 1220 |
* @param string $type Summary type. |
| 1221 |
* @param int $id Object id. |
| 1222 |
* @return true|WP_Error|null |
| 1223 |
*/ |
| 1224 |
function openstation_my_wordpress_woo_customer_summary_capability( $allowed, $type, $id ) { |
| 1225 |
unset( $id ); |
| 1226 |
if ( 'customer' !== $type ) { |
| 1227 |
return $allowed; |
| 1228 |
} |
| 1229 |
|
| 1230 |
return openstation_my_wordpress_woo_customers_permission(); |
| 1231 |
} |
| 1232 |
|
| 1233 |
/** |
| 1234 |
* Register the Customers routes. |
| 1235 |
* |
| 1236 |
* @return void |
| 1237 |
*/ |
| 1238 |
function openstation_my_wordpress_woo_register_customer_routes() { |
| 1239 |
if ( ! openstation_my_wordpress_woo_active() ) { |
| 1240 |
return; |
| 1241 |
} |
| 1242 |
|
| 1243 |
register_rest_route( |
| 1244 |
'desktop-mode/v1', |
| 1245 |
'/woocommerce/customers', |
| 1246 |
array( |
| 1247 |
'methods' => WP_REST_Server::READABLE, |
| 1248 |
'callback' => 'openstation_my_wordpress_woo_customers', |
| 1249 |
'permission_callback' => 'openstation_my_wordpress_woo_customers_permission', |
| 1250 |
'args' => array( |
| 1251 |
'page' => array( |
| 1252 |
'type' => 'integer', |
| 1253 |
'default' => 1, |
| 1254 |
), |
| 1255 |
'per_page' => array( |
| 1256 |
'type' => 'integer', |
| 1257 |
'default' => 24, |
| 1258 |
), |
| 1259 |
'search' => array( 'type' => 'string' ), |
| 1260 |
), |
| 1261 |
) |
| 1262 |
); |
| 1263 |
|
| 1264 |
register_rest_route( |
| 1265 |
'desktop-mode/v1', |
| 1266 |
'/woocommerce/customers/(?P<id>\d+)', |
| 1267 |
array( |
| 1268 |
'methods' => WP_REST_Server::READABLE, |
| 1269 |
'callback' => 'openstation_my_wordpress_woo_customer', |
| 1270 |
'permission_callback' => 'openstation_my_wordpress_woo_customers_permission', |
| 1271 |
'args' => array( |
| 1272 |
'id' => array( 'type' => 'integer' ), |
| 1273 |
), |
| 1274 |
) |
| 1275 |
); |
| 1276 |
} |
| 1277 |
add_action( 'rest_api_init', 'openstation_my_wordpress_woo_register_customer_routes' ); |
| 1278 |
|
| 1279 |
/* |
| 1280 |
------------------------------------------------------------------- |
| 1281 |
* The section |
| 1282 |
* ---------------------------------------------------------------- |
| 1283 |
*/ |
| 1284 |
|
| 1285 |
/** |
| 1286 |
* Append the Customers section to the Woo folder. |
| 1287 |
* |
| 1288 |
* Registered on the same filter as Orders and at the same priority, |
| 1289 |
* so it lands next to it inside the folder rather than at the end of |
| 1290 |
* the entity list. |
| 1291 |
* |
| 1292 |
* @param array[] $entities Entity descriptors. |
| 1293 |
* @return array[] |
| 1294 |
*/ |
| 1295 |
function openstation_my_wordpress_woo_customer_entity( $entities ) { |
| 1296 |
if ( ! is_array( $entities ) || ! openstation_my_wordpress_woo_active() ) { |
| 1297 |
return $entities; |
| 1298 |
} |
| 1299 |
if ( true !== openstation_my_wordpress_woo_customers_permission() ) { |
| 1300 |
return $entities; |
| 1301 |
} |
| 1302 |
|
| 1303 |
$group = openstation_my_wordpress_woo_group( |
| 1304 |
array( |
| 1305 |
'id' => 'plugin:woocommerce', |
| 1306 |
'label' => 'WooCommerce', |
| 1307 |
'icon' => 'dashicons-admin-plugins', |
| 1308 |
'order' => 20, |
| 1309 |
), |
| 1310 |
'shop_order' |
| 1311 |
); |
| 1312 |
|
| 1313 |
$entities[] = array( |
| 1314 |
'id' => 'wc-customers', |
| 1315 |
'label' => __( 'Customers', 'desktop-mode' ), |
| 1316 |
'icon' => 'dashicons-groups', |
| 1317 |
'restPath' => 'desktop-mode/v1/woocommerce/customers', |
| 1318 |
// Renders through the built-in user kind: avatar tiles, the |
| 1319 |
// dossier pane, the footprint route, the drag-out seam. A |
| 1320 |
// customer is a person before it is a row of money. |
| 1321 |
'kind' => 'user', |
| 1322 |
// Keeps the facts payload from being stripped by `_fields`. |
| 1323 |
'listFields' => array( 'openstation_woo_customer' ), |
| 1324 |
'group' => $group['id'], |
| 1325 |
'groupLabel' => $group['label'], |
| 1326 |
'groupIcon' => $group['icon'], |
| 1327 |
'groupOrder' => $group['order'], |
| 1328 |
); |
| 1329 |
|
| 1330 |
return $entities; |
| 1331 |
} |
| 1332 |
|
| 1333 |
/** |
| 1334 |
* Add the people numbers to the Woo folder's Store panel. |
| 1335 |
* |
| 1336 |
* Guest revenue is here because it is the one figure the Customers |
| 1337 |
* section structurally cannot show: an order with no account has no |
| 1338 |
* tile to sit on. Reporting it as a line on the folder is the honest |
| 1339 |
* alternative to letting it disappear. |
| 1340 |
* |
| 1341 |
* @param array $data Store totals. |
| 1342 |
* @return array |
| 1343 |
*/ |
| 1344 |
function openstation_my_wordpress_woo_customer_store_totals( $data ) { |
| 1345 |
if ( ! is_array( $data ) || true !== openstation_my_wordpress_woo_customers_permission() ) { |
| 1346 |
return $data; |
| 1347 |
} |
| 1348 |
|
| 1349 |
$map = openstation_my_wordpress_woo_customer_spend_map(); |
| 1350 |
$guest = $map[0] ?? null; |
| 1351 |
$plan = openstation_my_wordpress_woo_customer_plan(); |
| 1352 |
|
| 1353 |
$data['customers'] = (int) ( $plan['customers'] ?? 0 ); |
| 1354 |
|
| 1355 |
// Past the ordering cap the plan holds no bands, so the band |
| 1356 |
// counts are not zero — they are unknown. Saying so is the whole |
| 1357 |
// point: a store with 40,000 customers reporting "0 VIPs" is a |
| 1358 |
// wrong answer stated confidently, which is worse than no answer. |
| 1359 |
$data['bandsCapped'] = ! empty( $plan['capped'] ); |
| 1360 |
if ( empty( $data['bandsCapped'] ) ) { |
| 1361 |
$data['vips'] = (int) ( ( $plan['counts'] ?? array() )['vip'] ?? 0 ); |
| 1362 |
$data['lapsed'] = (int) ( ( $plan['counts'] ?? array() )['lapsed'] ?? 0 ); |
| 1363 |
} |
| 1364 |
|
| 1365 |
$data['guestSpend'] = $guest && (float) $guest['spend'] > 0 |
| 1366 |
? openstation_my_wordpress_woo_price( (float) $guest['spend'] ) |
| 1367 |
: ''; |
| 1368 |
$data['guestOrders'] = $guest ? (int) $guest['orders'] : 0; |
| 1369 |
|
| 1370 |
return $data; |
| 1371 |
} |
| 1372 |
|
| 1373 |
/** |
| 1374 |
* Boot the Customers surface. |
| 1375 |
* |
| 1376 |
* @return void |
| 1377 |
*/ |
| 1378 |
function openstation_my_wordpress_woo_customers_boot() { |
| 1379 |
add_filter( 'openstation_my_wordpress_woo_store', 'openstation_my_wordpress_woo_customer_store_totals' ); |
| 1380 |
add_filter( 'openstation_my_wordpress_entities', 'openstation_my_wordpress_woo_customer_entity', 5 ); |
| 1381 |
add_filter( 'openstation_my_wordpress_woo_summary_type', 'openstation_my_wordpress_woo_customer_summary_filter', 10, 3 ); |
| 1382 |
add_filter( 'openstation_my_wordpress_woo_summary_capability', 'openstation_my_wordpress_woo_customer_summary_capability', 10, 3 ); |
| 1383 |
} |
| 1384 |
openstation_my_wordpress_woo_customers_boot(); |
| 1385 |
|