PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.0
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.0
4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / commerce / class-commerce-controller.php
superb-blocks / src / commerce Last commit date
class-commerce-controller.php 1 month ago
class-commerce-controller.php
851 lines
1 <?php
2
3 namespace SuperbAddons\Commerce;
4
5 defined('ABSPATH') || exit();
6
7 use SuperbAddons\Data\Controllers\RestController;
8
9 /**
10 * Commerce (WooCommerce) REST + server logic for the Add to Cart block.
11 *
12 * Routes: superbaddons/commerce/*
13 * GET /products/search (edit_posts, 60s transient)
14 * GET /product/{id} (edit_posts, 60s transient)
15 * GET /nonce (public, no session — lazy refresh on stale nonce)
16 * POST /add (public, nonce, inits session)
17 */
18 class CommerceController
19 {
20 const MINIMUM_WC_VERSION = '5.0';
21
22 const SEARCH_ROUTE = '/commerce/products/search';
23 const PRODUCT_ROUTE = '/commerce/product/(?P<id>\d+)';
24 const COUPON_SEARCH_ROUTE = '/commerce/coupon-search';
25 const NONCE_ROUTE = '/commerce/nonce';
26 const ADD_ROUTE = '/commerce/add';
27
28 const USER_META_RECENT_PICKS = 'superb_commerce_recent_product_picks';
29
30 public static function Initialize()
31 {
32 // WooCommerce typically loads after this plugin, so defer the WC check
33 // and route registration to `init` (runs before `rest_api_init`).
34 add_action('init', array(__CLASS__, 'RegisterRoutes'), 5);
35 }
36
37 public static function RegisterRoutes()
38 {
39 if (!self::IsWcActive()) {
40 return;
41 }
42 if (!self::IsWcVersionSupported()) {
43 return;
44 }
45
46 RestController::AddRoute(self::SEARCH_ROUTE, array(
47 'methods' => 'GET',
48 'permission_callback' => array(__CLASS__, 'EditorPermissionCheck'),
49 'callback' => array(__CLASS__, 'SearchProductsCallback'),
50 ));
51
52 RestController::AddRoute(self::PRODUCT_ROUTE, array(
53 'methods' => 'GET',
54 'permission_callback' => array(__CLASS__, 'EditorPermissionCheck'),
55 'callback' => array(__CLASS__, 'GetProductCallback'),
56 ));
57
58 RestController::AddRoute(self::COUPON_SEARCH_ROUTE, array(
59 'methods' => 'GET',
60 'permission_callback' => array(__CLASS__, 'CouponPermissionCheck'),
61 'callback' => array(__CLASS__, 'SearchCouponsCallback'),
62 ));
63
64 RestController::AddRoute(self::NONCE_ROUTE, array(
65 'methods' => 'GET',
66 'permission_callback' => '__return_true',
67 'callback' => array(__CLASS__, 'NonceCallback'),
68 ));
69
70 RestController::AddRoute(self::ADD_ROUTE, array(
71 'methods' => 'POST',
72 'permission_callback' => array(__CLASS__, 'PublicNonceCheck'),
73 'callback' => array(__CLASS__, 'AddCallback'),
74 ));
75 }
76
77 // ─────────────────────────────────────────────────────────────────────
78 // Environment helpers
79 // ─────────────────────────────────────────────────────────────────────
80
81 public static function IsWcActive()
82 {
83 return function_exists('WC');
84 }
85
86 public static function IsWcVersionSupported()
87 {
88 if (!defined('WC_VERSION')) {
89 return false;
90 }
91 return version_compare(WC_VERSION, self::MINIMUM_WC_VERSION, '>=');
92 }
93
94 public static function EditorPermissionCheck()
95 {
96 return current_user_can('edit_posts');
97 }
98
99 public static function CouponPermissionCheck()
100 {
101 if (!current_user_can('edit_shop_coupons')) {
102 return new \WP_Error(
103 'rest_forbidden',
104 __('You do not have permission to manage coupons.', 'superb-blocks'),
105 array('status' => 403)
106 );
107 }
108 return true;
109 }
110
111 /**
112 * Permission callback for "public" endpoints: anyone may call, but the
113 * standard WP REST nonce must verify. For logged-in users the cookie-auth
114 * pipeline already checks this; we re-check here so anonymous visitors
115 * can't bypass the check.
116 */
117 public static function PublicNonceCheck($request)
118 {
119 $nonce = $request->get_header('x-wp-nonce');
120 if (empty($nonce) || !wp_verify_nonce($nonce, 'wp_rest')) {
121 return new \WP_Error(
122 'rest_forbidden',
123 __('Invalid or missing security token.', 'superb-blocks'),
124 array('status' => 403)
125 );
126 }
127 return true;
128 }
129
130 // ─────────────────────────────────────────────────────────────────────
131 // /commerce/products/search
132 // ─────────────────────────────────────────────────────────────────────
133
134 public static function SearchProductsCallback($request)
135 {
136 $term = isset($request['term']) ? sanitize_text_field((string) $request['term']) : '';
137 $limit = isset($request['limit']) ? intval($request['limit']) : 20;
138 if ($limit < 1) {
139 $limit = 1;
140 }
141 if ($limit > 50) {
142 $limit = 50;
143 }
144
145 $digest = md5($term . '|' . $limit);
146 $bucket = (int) floor(time() / DAY_IN_SECONDS);
147 $cache_key = 'superb_commerce_search_' . $digest . '_' . $bucket;
148
149 $cached = get_transient($cache_key);
150 if (is_array($cached)) {
151 return rest_ensure_response($cached);
152 }
153
154 $args = array(
155 'status' => 'publish',
156 'limit' => $limit,
157 'orderby' => 'date',
158 'order' => 'DESC',
159 'return' => 'objects',
160 'visibility' => array('catalog', 'search', 'visible'),
161 );
162 if ($term !== '') {
163 if (ctype_digit($term)) {
164 $args['include'] = array(intval($term));
165 } else {
166 $args['s'] = $term;
167 }
168 }
169
170 $products = wc_get_products($args);
171 $out = array();
172 if (is_array($products)) {
173 foreach ($products as $product) {
174 if (!$product) {
175 continue;
176 }
177 $row = self::SummarizeProductForSearch($product);
178 if ($row !== null) {
179 $out[] = $row;
180 }
181 }
182 }
183
184 $response = array('products' => $out);
185 set_transient($cache_key, $response, 60);
186 return rest_ensure_response($response);
187 }
188
189 private static function SummarizeProductForSearch($product)
190 {
191 $id = intval($product->get_id());
192 if ($id <= 0) {
193 return null;
194 }
195 $type = $product->get_type();
196 $supported = !in_array($type, array('grouped', 'external'), true);
197
198 $thumb_id = intval($product->get_image_id());
199 $thumb_url = '';
200 if ($thumb_id > 0) {
201 $thumb_url = wp_get_attachment_image_url($thumb_id, 'thumbnail');
202 if (!$thumb_url) {
203 $thumb_url = '';
204 }
205 }
206
207 return array(
208 'id' => $id,
209 'name' => wp_strip_all_tags($product->get_name()),
210 'sku' => (string) $product->get_sku(),
211 'type' => $type,
212 'supported' => $supported,
213 'thumbnail_url' => esc_url_raw($thumb_url),
214 'price' => self::StructuredPrice($product),
215 );
216 }
217
218 // ─────────────────────────────────────────────────────────────────────
219 // /commerce/coupon-search (admin picker)
220 // ─────────────────────────────────────────────────────────────────────
221
222 public static function SearchCouponsCallback($request)
223 {
224 nocache_headers();
225
226 $search = isset($request['search']) ? sanitize_text_field((string) $request['search']) : '';
227 $per_page = isset($request['per_page']) ? intval($request['per_page']) : 30;
228 if ($per_page < 1) {
229 $per_page = 1;
230 }
231 if ($per_page > 100) {
232 $per_page = 100;
233 }
234
235 $digest = md5($search . '|' . $per_page);
236 $bucket = (int) floor(time() / DAY_IN_SECONDS);
237 $cache_key = 'superb_commerce_coupon_search_' . $digest . '_' . $bucket;
238
239 $cached = get_transient($cache_key);
240 if (!is_array($cached)) {
241 $args = array(
242 'post_type' => 'shop_coupon',
243 'post_status' => 'publish',
244 'posts_per_page' => $per_page,
245 'orderby' => 'title',
246 'order' => 'ASC',
247 );
248 if ($search !== '') {
249 $args['s'] = $search;
250 }
251
252 $query = new \WP_Query($args);
253 $out = array();
254 if (!empty($query->posts)) {
255 foreach ($query->posts as $post) {
256 if (!$post || !isset($post->post_title)) {
257 continue;
258 }
259 $row = self::SummarizeCouponForSearch($post);
260 if ($row !== null) {
261 $out[] = $row;
262 }
263 }
264 }
265 $cached = $out;
266 set_transient($cache_key, $cached, 60);
267 }
268
269 $resp = rest_ensure_response($cached);
270 $resp->header('Cache-Control', 'no-store, private');
271 return $resp;
272 }
273
274 private static function SummarizeCouponForSearch($post)
275 {
276 $code = wp_strip_all_tags((string) $post->post_title);
277 if ($code === '') {
278 return null;
279 }
280
281 $coupon = function_exists('new_WC_Coupon') ? null : null;
282 if (class_exists('\\WC_Coupon')) {
283 $coupon = new \WC_Coupon($code);
284 }
285
286 $discount_type = '';
287 $amount = '';
288 $expires_ts = 0;
289 $description = wp_strip_all_tags((string) $post->post_excerpt);
290
291 if ($coupon) {
292 $discount_type = (string) $coupon->get_discount_type();
293 $amount = (string) $coupon->get_amount();
294 $expiry = $coupon->get_date_expires();
295 if ($expiry && method_exists($expiry, 'getTimestamp')) {
296 $expires_ts = intval($expiry->getTimestamp());
297 }
298 if ($description === '') {
299 $description = wp_strip_all_tags((string) $coupon->get_description());
300 }
301 }
302
303 $is_expired = $expires_ts > 0 && $expires_ts < time();
304
305 return array(
306 'code' => $code,
307 'description' => $description,
308 'discount_type' => $discount_type,
309 'amount' => $amount,
310 'expires' => $expires_ts > 0 ? $expires_ts : null,
311 'is_expired' => (bool) $is_expired,
312 );
313 }
314
315 // ─────────────────────────────────────────────────────────────────────
316 // /commerce/product/{id}
317 // ─────────────────────────────────────────────────────────────────────
318
319 public static function GetProductCallback($request)
320 {
321 $id = intval($request['id']);
322 if ($id <= 0) {
323 return new \WP_Error('invalid_id', __('Invalid product ID.', 'superb-blocks'), array('status' => 400));
324 }
325
326 $product = wc_get_product($id);
327 if (!$product) {
328 return new \WP_Error('not_found', __('Product not found.', 'superb-blocks'), array('status' => 404));
329 }
330 if ($product->get_status() !== 'publish') {
331 return new \WP_Error('not_available', __('Product is not available.', 'superb-blocks'), array('status' => 404));
332 }
333 $visibility = $product->get_catalog_visibility();
334 if (!in_array($visibility, array('catalog', 'search', 'visible'), true)) {
335 return new \WP_Error('not_available', __('Product is not available.', 'superb-blocks'), array('status' => 404));
336 }
337
338 $mtime = self::ProductMtime($product);
339 $cache_key = 'superb_commerce_product_' . $id . '_' . $mtime;
340 $cached = get_transient($cache_key);
341 if (is_array($cached)) {
342 return rest_ensure_response($cached);
343 }
344
345 $response = self::HydrateProduct($product);
346 set_transient($cache_key, $response, 60);
347 return rest_ensure_response($response);
348 }
349
350 private static function HydrateProduct($product)
351 {
352 $id = intval($product->get_id());
353 $type = $product->get_type();
354 $supported = !in_array($type, array('grouped', 'external'), true);
355
356 $image_id = intval($product->get_image_id());
357 $image = array(
358 'full' => '',
359 'medium' => '',
360 'thumbnail' => '',
361 );
362 if ($image_id > 0) {
363 $image['full'] = esc_url_raw((string) wp_get_attachment_image_url($image_id, 'full'));
364 $image['medium'] = esc_url_raw((string) wp_get_attachment_image_url($image_id, 'medium'));
365 $image['thumbnail'] = esc_url_raw((string) wp_get_attachment_image_url($image_id, 'thumbnail'));
366 }
367
368 $variations = array();
369 $variation_attributes = array();
370 if ($type === 'variable' && method_exists($product, 'get_available_variations') && method_exists($product, 'get_variation_attributes')) {
371 $raw_variations = $product->get_available_variations();
372 if (is_array($raw_variations)) {
373 foreach ($raw_variations as $v) {
374 if (!is_array($v) || empty($v['variation_id'])) {
375 continue;
376 }
377 $vid = intval($v['variation_id']);
378 $variation_product = wc_get_product($vid);
379 if (!$variation_product) {
380 continue;
381 }
382
383 $v_image = array();
384 $v_image_id = intval($variation_product->get_image_id());
385 if ($v_image_id > 0) {
386 $v_image['full'] = esc_url_raw((string) wp_get_attachment_image_url($v_image_id, 'full'));
387 $v_image['medium'] = esc_url_raw((string) wp_get_attachment_image_url($v_image_id, 'medium'));
388 $v_image['thumbnail'] = esc_url_raw((string) wp_get_attachment_image_url($v_image_id, 'thumbnail'));
389 }
390
391 // Filter out subscription synthetic attrs (_subscription_period etc.)
392 $raw_attrs = isset($v['attributes']) && is_array($v['attributes']) ? $v['attributes'] : array();
393 $clean_attrs = array();
394 foreach ($raw_attrs as $ak => $av) {
395 $lower = strtolower($ak);
396 if (strpos($lower, '_subscription_') !== false) {
397 continue;
398 }
399 $clean_attrs[$ak] = (string) $av;
400 }
401
402 $variations[] = array(
403 'variation_id' => $vid,
404 'attributes' => $clean_attrs,
405 'price' => self::StructuredPrice($variation_product),
406 'stock' => self::StructuredStock($variation_product),
407 'image' => $v_image,
408 'is_purchasable' => (bool) $variation_product->is_purchasable(),
409 );
410 }
411 }
412
413 $raw_attrs = $product->get_variation_attributes();
414 if (is_array($raw_attrs)) {
415 foreach ($raw_attrs as $attr_name => $options) {
416 if (strpos(strtolower($attr_name), '_subscription_') !== false) {
417 continue;
418 }
419 $attribute_key = sanitize_title($attr_name);
420 $label = wc_attribute_label($attr_name, $product);
421 $normalized_options = array();
422 if (is_array($options)) {
423 foreach ($options as $opt) {
424 $normalized_options[] = array(
425 'value' => (string) $opt,
426 'label' => self::AttributeOptionLabel($attr_name, $opt),
427 );
428 }
429 }
430 $variation_attributes[] = array(
431 'name' => $attr_name,
432 'key' => 'attribute_' . $attribute_key,
433 'label' => wp_strip_all_tags((string) $label),
434 'options' => $normalized_options,
435 );
436 }
437 }
438 }
439
440 return array(
441 'id' => $id,
442 'name' => wp_strip_all_tags($product->get_name()),
443 'permalink' => esc_url_raw((string) $product->get_permalink()),
444 'type' => $type,
445 'supported' => $supported,
446 'sku' => (string) $product->get_sku(),
447 'is_purchasable' => (bool) $product->is_purchasable(),
448 'price' => self::StructuredPrice($product),
449 'stock' => self::StructuredStock($product),
450 'image' => $image,
451 'variations' => $variations,
452 'variation_attributes' => $variation_attributes,
453 );
454 }
455
456 private static function AttributeOptionLabel($attr_name, $option_value)
457 {
458 if (taxonomy_exists($attr_name)) {
459 $term = get_term_by('slug', $option_value, $attr_name);
460 if ($term && !is_wp_error($term)) {
461 return wp_strip_all_tags((string) $term->name);
462 }
463 }
464 return wp_strip_all_tags((string) $option_value);
465 }
466
467 // ─────────────────────────────────────────────────────────────────────
468 // Structured price + stock payloads (no HTML)
469 // ─────────────────────────────────────────────────────────────────────
470
471 public static function StructuredPrice($product)
472 {
473 if (!$product) {
474 return null;
475 }
476 $regular = $product->get_regular_price();
477 $sale = $product->get_sale_price();
478 $current = $product->get_price();
479
480 $on_sale = $sale !== '' && $sale !== null && $product->is_on_sale();
481
482 $suffix_html = '';
483 if (function_exists('wc_get_price_suffix')) {
484 $suffix_html = (string) wc_get_price_suffix($product);
485 }
486
487 return array(
488 'regular' => $regular === '' ? null : (string) $regular,
489 'sale' => $on_sale && $sale !== '' ? (string) $sale : null,
490 'current' => $current === '' ? null : (string) $current,
491 'on_sale' => $on_sale,
492 'currency_symbol' => html_entity_decode(get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8'),
493 'currency_position' => (string) get_option('woocommerce_currency_pos', 'left'),
494 'decimal_separator' => function_exists('wc_get_price_decimal_separator') ? (string) wc_get_price_decimal_separator() : '.',
495 'thousand_separator' => function_exists('wc_get_price_thousand_separator') ? (string) wc_get_price_thousand_separator() : ',',
496 'decimals' => function_exists('wc_get_price_decimals') ? intval(wc_get_price_decimals()) : 2,
497 'price_suffix' => wp_strip_all_tags($suffix_html),
498 );
499 }
500
501 public static function StructuredStock($product)
502 {
503 if (!$product) {
504 return null;
505 }
506 $status = (string) $product->get_stock_status();
507 $qty = $product->get_stock_quantity();
508 $managing = (bool) $product->managing_stock();
509 $backorders_allowed = (bool) $product->backorders_allowed();
510
511 $low_threshold = intval(get_option('woocommerce_notify_low_stock_amount', 2));
512
513 return array(
514 'status' => $status,
515 'quantity' => $qty === null ? null : intval($qty),
516 'manages_stock' => $managing,
517 'backorders_allowed' => $backorders_allowed,
518 'low_threshold' => $low_threshold,
519 'is_in_stock' => (bool) $product->is_in_stock(),
520 );
521 }
522
523 private static function ProductMtime($product)
524 {
525 $id = intval($product->get_id());
526 $post = get_post($id);
527 if (!$post) {
528 return 0;
529 }
530 return strtotime($post->post_modified_gmt . ' UTC');
531 }
532
533 // ─────────────────────────────────────────────────────────────────────
534 // /commerce/nonce (lazy refresh for stale localized nonces — full-page cache
535 // straddling a tick boundary, or user session rotated since render)
536 // ─────────────────────────────────────────────────────────────────────
537
538 public static function NonceCallback()
539 {
540 nocache_headers();
541
542 // rest_cookie_check_errors() resets the current user to 0 on cookie-authed
543 // REST requests that carry no X-WP-Nonce (this endpoint, by design). If we
544 // call wp_create_nonce() in that state, the nonce hashes against user 0 and
545 // later fails wp_verify_nonce() when the real user hits /add etc. — producing
546 // rest_cookie_invalid_nonce. Re-resolve the user from the auth cookie first.
547 if (!is_user_logged_in()) {
548 $user_id = wp_validate_auth_cookie('', 'logged_in');
549 if ($user_id) {
550 wp_set_current_user($user_id);
551 }
552 }
553
554 $response = rest_ensure_response(array(
555 'nonce' => wp_create_nonce('wp_rest'),
556 ));
557 $response->header('Cache-Control', 'no-store, private');
558 return $response;
559 }
560
561 // ─────────────────────────────────────────────────────────────────────
562 // /commerce/add (AJAX mode)
563 // ─────────────────────────────────────────────────────────────────────
564
565 public static function AddCallback($request)
566 {
567 nocache_headers();
568 $params = $request->get_json_params();
569 if (!is_array($params)) {
570 $params = array();
571 }
572
573 $result = self::AddItemsToCart($params);
574 if (is_wp_error($result)) {
575 return $result;
576 }
577
578 $cart = WC()->cart;
579 // WooCommerce-defined filter; we apply it here to return the standard mini-cart fragments WC themes expect.
580 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
581 $fragments = apply_filters('woocommerce_add_to_cart_fragments', array());
582 $cart_hash = $cart && method_exists($cart, 'get_cart_hash') ? (string) $cart->get_cart_hash() : '';
583
584 $resp = rest_ensure_response(array(
585 'ok' => true,
586 'fragments' => $fragments,
587 'cartHash' => $cart_hash,
588 ));
589 $resp->header('Cache-Control', 'no-store, private');
590 return $resp;
591 }
592
593 // ─────────────────────────────────────────────────────────────────────
594 // Shared add logic
595 // ─────────────────────────────────────────────────────────────────────
596
597 /**
598 * Adds the requested product(s) to the cart, applies coupon inline if present,
599 * and returns a summary array suitable for the AJAX response, or WP_Error.
600 *
601 * Public so premium controllers (e.g. direct-checkout) can reuse the same
602 * validation + add pipeline without re-implementing it.
603 */
604 public static function AddItemsToCart($params)
605 {
606 self::BootstrapWcCart();
607 $cart = WC()->cart;
608 if (!$cart) {
609 return self::ErrorResponse('cart_unavailable', __('Could not access cart.', 'superb-blocks'), 500);
610 }
611
612 $product_id = isset($params['product_id']) ? intval($params['product_id']) : 0;
613 $variation_id = isset($params['variation_id']) ? intval($params['variation_id']) : 0;
614 $qty = isset($params['qty']) ? max(1, intval($params['qty'])) : 1;
615 $variation_attrs = array();
616 if (isset($params['variation_attributes']) && is_array($params['variation_attributes'])) {
617 foreach ($params['variation_attributes'] as $k => $v) {
618 $variation_attrs[sanitize_text_field((string) $k)] = sanitize_text_field((string) $v);
619 }
620 }
621 $extra_cart_item_data = array();
622 if (isset($params['cart_item_data']) && is_array($params['cart_item_data'])) {
623 foreach ($params['cart_item_data'] as $k => $v) {
624 $extra_cart_item_data[sanitize_key((string) $k)] = is_scalar($v) ? sanitize_text_field((string) $v) : $v;
625 }
626 }
627
628 if ($product_id <= 0) {
629 return self::ErrorResponse('invalid_product', __('Invalid product.', 'superb-blocks'), 400);
630 }
631
632 $product = wc_get_product($product_id);
633 if (!$product) {
634 return self::ErrorResponse('not_found', __('Product not found.', 'superb-blocks'), 404);
635 }
636 $type = $product->get_type();
637 if (in_array($type, array('grouped', 'external'), true)) {
638 return self::ErrorResponse('unsupported_type', __('This product type is not supported.', 'superb-blocks'), 400);
639 }
640 if (!$product->is_purchasable()) {
641 return self::ErrorResponse('not_purchasable', __('This product cannot be purchased.', 'superb-blocks'), 400);
642 }
643
644 $resolve = $variation_id > 0 ? wc_get_product($variation_id) : $product;
645 if (!$resolve) {
646 return self::ErrorResponse('invalid_variation', __('Invalid variation.', 'superb-blocks'), 400);
647 }
648 if (!$resolve->is_in_stock()) {
649 return self::ErrorResponse('out_of_stock', __('This product is out of stock.', 'superb-blocks'), 400);
650 }
651 if (!$resolve->has_enough_stock($qty)) {
652 $stock_qty = $resolve->get_stock_quantity();
653 return self::ErrorResponse(
654 'insufficient_stock',
655 sprintf(
656 /* translators: %d: number of units available */
657 __('Only %d left in stock.', 'superb-blocks'),
658 $stock_qty === null ? 0 : intval($stock_qty)
659 ),
660 400,
661 array('stock_remaining' => $stock_qty === null ? 0 : intval($stock_qty))
662 );
663 }
664
665 $cart_item_key = $cart->add_to_cart($product_id, $qty, $variation_id, $variation_attrs, $extra_cart_item_data);
666 if (!$cart_item_key) {
667 // Collect WC notices if any, otherwise generic
668 $notices_msg = '';
669 if (function_exists('wc_get_notices')) {
670 $notices = wc_get_notices('error');
671 if (is_array($notices) && !empty($notices)) {
672 foreach ($notices as $n) {
673 $notices_msg .= ' ' . (is_array($n) && isset($n['notice']) ? wp_strip_all_tags((string) $n['notice']) : wp_strip_all_tags((string) $n));
674 }
675 wc_clear_notices();
676 }
677 }
678 return self::ErrorResponse(
679 'add_failed',
680 trim($notices_msg) !== '' ? trim($notices_msg) : __('Could not add the item to the cart.', 'superb-blocks'),
681 400
682 );
683 }
684
685 // Apply coupon inline (never blocks the add).
686 // Coupon outcome is not surfaced — admins diagnose via their WC cart view.
687 $coupon_code = isset($params['coupon']) ? sanitize_text_field((string) $params['coupon']) : '';
688 if ($coupon_code !== '') {
689 $cart->apply_coupon($coupon_code);
690 if (function_exists('wc_get_notices')) {
691 wc_clear_notices();
692 }
693 }
694
695 return array(
696 'cart_item_key' => $cart_item_key,
697 );
698 }
699
700 /**
701 * WC bootstrap: session + cart may not yet be ready at rest_api_init time.
702 * Call before any WC()->cart access.
703 */
704 public static function BootstrapWcCart()
705 {
706 if (!function_exists('WC')) {
707 return;
708 }
709 $wc = WC();
710 if (!$wc) {
711 return;
712 }
713 if (method_exists($wc, 'initialize_session') && (!isset($wc->session) || !$wc->session)) {
714 $wc->initialize_session();
715 }
716 if (method_exists($wc, 'initialize_cart') && (!isset($wc->cart) || !$wc->cart)) {
717 $wc->initialize_cart();
718 }
719 if (function_exists('wc_load_cart')) {
720 // Extra guard for edge cases where cart object is null post-init.
721 if (!$wc->cart) {
722 wc_load_cart();
723 }
724 }
725 }
726
727 // ─────────────────────────────────────────────────────────────────────
728 // Render callback
729 // ─────────────────────────────────────────────────────────────────────
730
731 /**
732 * Server render for the Add to Cart block. Resolves product (specific or context-driven),
733 * enqueues the frontend script, and rewrites data-product-id when needed.
734 */
735 public static function RenderBlock($attributes, $content, $block = null)
736 {
737 if (!self::IsWcActive()) {
738 $is_editor = self::IsEditorRequest();
739 return $is_editor ? '<!-- Superb Add to Cart: WooCommerce not active -->' : '';
740 }
741
742 $attrs = is_array($attributes) ? $attributes : array();
743 $product_source = isset($attrs['productSource']) ? sanitize_key((string) $attrs['productSource']) : 'specific';
744
745 $resolved_id = 0;
746 if ($product_source === 'current') {
747 $resolved_id = self::ResolveContextProductId($block);
748 } else {
749 $resolved_id = isset($attrs['productId']) ? intval($attrs['productId']) : 0;
750 }
751
752 if ($resolved_id <= 0) {
753 $is_editor = self::IsEditorRequest();
754 return $is_editor ? '<!-- Superb Add to Cart: no product resolved -->' : '';
755 }
756
757 $product = wc_get_product($resolved_id);
758 if (!$product || $product->get_status() !== 'publish') {
759 $is_editor = self::IsEditorRequest();
760 return $is_editor ? '<!-- Superb Add to Cart: product unavailable -->' : '';
761 }
762 $type = $product->get_type();
763 if (in_array($type, array('grouped', 'external'), true)) {
764 $is_editor = self::IsEditorRequest();
765 return $is_editor ? '<!-- Superb Add to Cart: unsupported product type -->' : '';
766 }
767
768 \SuperbAddons\Gutenberg\BlocksAPI\Controllers\DynamicBlockAssets::EnqueueAddToCart($attrs, $content);
769
770 $unique_id = wp_unique_id('atc-');
771
772 // Rewrite data-product-id in the saved HTML so the frontend JS uses the resolved ID.
773 $processor = new \WP_HTML_Tag_Processor($content);
774 if ($processor->next_tag()) {
775 $processor->set_attribute('data-product-id', (string) $resolved_id);
776 if ($product_source === 'current') {
777 $processor->set_attribute('data-product-source', 'current');
778 }
779 $existing_id = $processor->get_attribute('id');
780 if (empty($existing_id)) {
781 $processor->set_attribute('id', $unique_id);
782 }
783 $content = $processor->get_updated_html();
784 }
785
786 return $content;
787 }
788
789 /**
790 * Resolve product ID from block context (postId/postType) with queried-object fallback.
791 */
792 public static function ResolveContextProductId($block)
793 {
794 if ($block && isset($block->context) && is_array($block->context)) {
795 $ctx_type = isset($block->context['postType']) ? (string) $block->context['postType'] : '';
796 $ctx_id = isset($block->context['postId']) ? intval($block->context['postId']) : 0;
797 if ($ctx_type === 'product' && $ctx_id > 0) {
798 return $ctx_id;
799 }
800 }
801
802 // Queried-object fallback for Single Product templates.
803 $obj = function_exists('get_queried_object') ? get_queried_object() : null;
804 if ($obj instanceof \WP_Post && isset($obj->post_type) && $obj->post_type === 'product') {
805 return intval($obj->ID);
806 }
807
808 return 0;
809 }
810
811 private static function IsEditorRequest()
812 {
813 if (defined('REST_REQUEST') && REST_REQUEST) {
814 return true;
815 }
816 if (is_admin()) {
817 return true;
818 }
819 return false;
820 }
821
822 // ─────────────────────────────────────────────────────────────────────
823 // Helpers
824 // ─────────────────────────────────────────────────────────────────────
825
826 public static function ErrorResponse($code, $message, $status, $extra = array())
827 {
828 $data = array('status' => intval($status));
829 if (is_array($extra) && !empty($extra)) {
830 $data = array_merge($data, $extra);
831 }
832 return new \WP_Error($code, $message, $data);
833 }
834
835 /**
836 * Return a per-user list of recent product picks (ids).
837 */
838 public static function GetRecentPicks($user_id)
839 {
840 $user_id = intval($user_id);
841 if ($user_id <= 0) {
842 return array();
843 }
844 $raw = get_user_meta($user_id, self::USER_META_RECENT_PICKS, true);
845 if (!is_array($raw)) {
846 return array();
847 }
848 return array_values(array_filter(array_map('intval', $raw)));
849 }
850 }
851