PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / 2.1.8
bBlocks – Essential Gutenberg Blocks & Patterns Collection v2.1.8
2.1.8 2.1.7 2.1.6 2.1.5 2.1.4 2.1.3 2.1.2 2.1.1 2.1.0 2.0.43 2.0.42 2.0.41 2.0.40 2.0.39 2.0.38 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.5.1 1.5.2 1.5.3 All 108 releases
b-blocks / includes / blocks / woo-single-product / WooSingleProduct.php

WooSingleProduct.php in bBlocks – Essential Gutenberg Blocks & Patterns Collection 2.1.8, at includes/blocks/woo-single-product/WooSingleProduct.php

241 lines 9.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Woo Single Product — shared server logic.
4 *
5 * Responsibilities:
6 * - Sanitize the block attribute set into a safe, typed array.
7 * - Render the native WooCommerce single-product purchase area for one chosen
8 * product inside an isolated wc_setup_product_data() context, firing
9 * WooCommerce's own template hooks (woocommerce_show_product_images,
10 * woocommerce_single_product_summary, woocommerce_after_single_product_summary)
11 * so the gallery, variation form, native add-to-cart form and meta render
12 * exactly as on the canonical product page.
13 *
14 * No custom add-to-cart AJAX endpoint or nonce is needed: WooCommerce's own
15 * enqueued scripts (wc-single-product, wc-add-to-cart-variation) handle all
16 * interactivity. After rendering, the product/post globals are always reset so
17 * the rest of the page is not polluted.
18 *
19 * @package bBlocks
20 */
21
22 namespace BBlocks\Inc\Blocks;
23
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 class WooSingleProduct {
29
30 /**
31 * Clamp a value to an integer range.
32 *
33 * @param mixed $value Raw value.
34 * @param int $min Minimum.
35 * @param int $max Maximum.
36 * @param int $fallback Fallback when non-numeric.
37 * @return int
38 */
39 public static function clampInt( $value, $min, $max, $fallback ) {
40 if ( ! is_numeric( $value ) ) {
41 return (int) $fallback;
42 }
43 $value = (int) $value;
44 if ( $value < $min ) {
45 return (int) $min;
46 }
47 if ( $value > $max ) {
48 return (int) $max;
49 }
50 return $value;
51 }
52
53 /**
54 * Pick a value from an allowlist.
55 *
56 * @param mixed $value Raw value.
57 * @param string[] $allowed Allowed values.
58 * @param string $fallback Fallback.
59 * @return string
60 */
61 public static function pickFrom( $value, array $allowed, $fallback ) {
62 $value = is_string( $value ) ? trim( $value ) : '';
63 return in_array( $value, $allowed, true ) ? $value : $fallback;
64 }
65
66 /**
67 * Sanitize a CSS color value (hex, rgb/hsl, var(), or a CSS keyword).
68 *
69 * @param mixed $color Raw color.
70 * @param string $fallback Fallback when invalid.
71 * @return string
72 */
73 public static function sanitizeColor( $color, $fallback = '' ) {
74 $color = trim( (string) $color );
75 if ( '' === $color ) {
76 return $fallback;
77 }
78 if ( preg_match( '/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
79 return $color;
80 }
81 if ( preg_match( '/^(rgb|rgba|hsl|hsla)\s*\([0-9\s,%.\/]+\)$/i', $color ) ) {
82 return $color;
83 }
84 if ( preg_match( '/^var\(\s*--[a-zA-Z0-9\-_]+\s*(,\s*[a-zA-Z0-9 #%.,\-_\/]+)?\s*\)$/', $color ) ) {
85 return $color;
86 }
87 if ( preg_match( '/^[a-zA-Z]{1,30}$/', $color ) ) {
88 return $color;
89 }
90 return $fallback;
91 }
92
93 /**
94 * Normalize and sanitize the full attribute set into a safe, typed array.
95 *
96 * @param array $attributes Raw block attributes.
97 * @return array
98 */
99 public static function resolveAttributes( array $attributes ) {
100 $noProductMessage = isset( $attributes['noProductMessage'] ) ? wp_strip_all_tags( (string) $attributes['noProductMessage'] ) : '';
101 $noProductMessage = '' !== trim( $noProductMessage ) ? $noProductMessage : __( 'Please select a product.', 'b-blocks' );
102
103 return [
104 'productId' => self::clampInt( $attributes['productId'] ?? 0, 0, PHP_INT_MAX, 0 ),
105 'layout' => self::pickFrom( $attributes['layout'] ?? 'horizontal', [ 'horizontal', 'vertical' ], 'horizontal' ),
106 'gallerySplit' => self::clampInt( $attributes['gallerySplit'] ?? 50, 20, 70, 50 ),
107 'showGallery' => ! isset( $attributes['showGallery'] ) || (bool) $attributes['showGallery'],
108 'showSummary' => ! isset( $attributes['showSummary'] ) || (bool) $attributes['showSummary'],
109 'showAfterSummary' => ! empty( $attributes['showAfterSummary'] ),
110 'columnGap' => self::clampInt( $attributes['columnGap'] ?? 32, 0, 80, 32 ),
111 'maxWidth' => self::clampInt( $attributes['maxWidth'] ?? 0, 0, 1600, 0 ),
112 'blockAlign' => self::pickFrom( $attributes['blockAlign'] ?? 'center', [ 'left', 'center', 'right' ], 'center' ),
113 'paddingVertical' => self::clampInt( $attributes['paddingVertical'] ?? 0, 0, 120, 0 ),
114 'paddingHorizontal' => self::clampInt( $attributes['paddingHorizontal'] ?? 0, 0, 120, 0 ),
115 'blockBG' => self::sanitizeColor( $attributes['blockBG'] ?? '', 'transparent' ),
116 'blockBorderRadius' => self::clampInt( $attributes['blockBorderRadius'] ?? 0, 0, 48, 0 ),
117 'mobileStackOrder' => self::pickFrom( $attributes['mobileStackOrder'] ?? 'gallery-first', [ 'gallery-first', 'summary-first' ], 'gallery-first' ),
118 'mobileBreakpoint' => self::clampInt( $attributes['mobileBreakpoint'] ?? 767, 480, 960, 767 ),
119 'noProductMessage' => $noProductMessage,
120 ];
121 }
122
123 /**
124 * Demote the first <h1> in an HTML string to <h2>.
125 *
126 * WooCommerce's single-product title template wraps the_title() output in
127 * <h1 class="product_title entry-title">. Because this block is embedded on a
128 * non-product page, an <h1> would harm the document heading outline. We apply
129 * this transformation on the fully-rendered ob_get_clean() HTML (not via the
130 * the_title filter, which only sees the raw text of the title) so the first
131 * <h1>...</h1> pair inside the summary wrapper is reliably swapped to <h2>.
132 *
133 * @param string $html Rendered HTML of the bb-wsp-summary section.
134 * @return string
135 */
136 public static function demoteTitleHeading( $html ) {
137 if ( ! is_string( $html ) ) {
138 return $html;
139 }
140 $replaced = preg_replace(
141 [ '/<h1(\s|>)/i', '/<\/h1>/i' ],
142 [ '<h2$1', '</h2>' ],
143 $html,
144 1
145 );
146 return null === $replaced ? $html : $replaced;
147 }
148
149 /**
150 * Render the native purchase area for one product.
151 *
152 * Sets up the global product/post context, fires WooCommerce's own template
153 * hooks for the gallery, summary and (optionally) after-summary sections,
154 * then resets the globals. Returns the rendered HTML string (the gallery and
155 * summary column wrappers only — the outer block wrapper is added by render.php).
156 *
157 * @param WC_Product $product The resolved, visible product.
158 * @param array $a Sanitized attributes from resolveAttributes().
159 * @return string
160 */
161 public static function renderProductArea( $product, array $a ) {
162 // Save the globals we are about to overwrite so we can restore them
163 // after rendering and never pollute the main query / page context.
164 $savedPost = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
165 $savedProduct = isset( $GLOBALS['product'] ) ? $GLOBALS['product'] : null;
166
167 // Set the global $product / $post WooCommerce template functions expect.
168 $postObject = get_post( $product->get_id() );
169 if ( $postObject instanceof \WP_Post ) {
170 $GLOBALS['post'] = $postObject; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below.
171 setup_postdata( $postObject );
172 }
173 wc_setup_product_data( $postObject instanceof \WP_Post ? $postObject : $product->get_id() );
174
175 ob_start();
176
177 if ( $a['showGallery'] ) {
178 echo '<div class="bb-wsp-gallery">';
179 /**
180 * Fires WooCommerce's native product image gallery (featured image +
181 * thumbnail strip, sale flash, zoom/lightbox triggers).
182 */
183 woocommerce_show_product_images();
184 echo '</div>';
185 }
186
187 if ( $a['showSummary'] ) {
188 ob_start();
189
190 /**
191 * Fires the native summary column: title, rating, price, short
192 * description, the add-to-cart form (including variation selectors
193 * for variable products), and the product meta (SKU / categories /
194 * tags), in standard WooCommerce priority order.
195 */
196 do_action( 'woocommerce_single_product_summary' );
197
198 // Demote the first <h1> to <h2> in the rendered summary HTML so the
199 // embedded product does not create a duplicate top-level heading on
200 // the host page. Applied to the fully-buffered output so the regex
201 // can reliably match the <h1 class="product_title entry-title"> tag
202 // (the_title filter only sees the raw post title text, not the tag).
203 $summaryHtml = self::demoteTitleHeading( ob_get_clean() );
204
205 echo '<div class="bb-wsp-summary summary entry-summary">';
206 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- WooCommerce core template output with heading tag already demoted.
207 echo $summaryHtml;
208 echo '</div>';
209 }
210
211 if ( $a['showAfterSummary'] ) {
212 echo '<div class="bb-wsp-after-summary">';
213 /**
214 * Fires the native after-summary hooks: product data tabs
215 * (Description, Additional information, Reviews) and upsells.
216 */
217 do_action( 'woocommerce_after_single_product_summary' );
218 echo '</div>';
219 }
220
221 $html = ob_get_clean();
222
223 // Tear down the product context and restore the original globals so the
224 // rest of the page sees an untouched main query and product global.
225 if ( null !== $savedProduct ) {
226 $GLOBALS['product'] = $savedProduct; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring saved value.
227 } else {
228 unset( $GLOBALS['product'] );
229 }
230
231 if ( null !== $savedPost ) {
232 $GLOBALS['post'] = $savedPost; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring saved value.
233 setup_postdata( $savedPost );
234 } else {
235 wp_reset_postdata();
236 }
237
238 return $html;
239 }
240 }
241