| 1 |
<?php |
| 2 |
/** |
| 3 |
* ACF fields collected by the Woo Builder checkout and My Account widgets. |
| 4 |
* |
| 5 |
* @package King_Addons |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace King_Addons\Woo_Builder; |
| 9 |
|
| 10 |
if (!defined('ABSPATH')) { |
| 11 |
exit; |
| 12 |
} |
| 13 |
|
| 14 |
/** |
| 15 |
* Renders, validates and stores the fields of the Checkout ACF Extra Fields and |
| 16 |
* My Account ACF Extra Fields widgets. |
| 17 |
* |
| 18 |
* Checkout values are stored on the order under the ACF post id |
| 19 |
* "woo_order_{id}" - the address ACF PRO uses for WooCommerce orders, so |
| 20 |
* get_field('name', 'woo_order_123') reads them back. My Account values go to |
| 21 |
* the customer's profile, "user_{id}". |
| 22 |
*/ |
| 23 |
final class ACF_Fields |
| 24 |
{ |
| 25 |
/** |
| 26 |
* Hidden input carrying the signed field list of one checkout block. |
| 27 |
*/ |
| 28 |
private const CHECKOUT_INPUT = 'ka_acf_checkout'; |
| 29 |
|
| 30 |
/** |
| 31 |
* Hidden input carrying the signed field list of one account form. |
| 32 |
*/ |
| 33 |
private const ACCOUNT_INPUT = 'ka_acf_account'; |
| 34 |
|
| 35 |
/** |
| 36 |
* Order meta recording which fields the checkout stored on the order. |
| 37 |
*/ |
| 38 |
private const ORDER_META = '_ka_acf_checkout_fields'; |
| 39 |
|
| 40 |
/** |
| 41 |
* Field types a visitor cannot fill in here: uploads need an uploader and a |
| 42 |
* multipart request, which the AJAX checkout does not send; tabs and |
| 43 |
* accordions only make sense inside a full ACF form. |
| 44 |
*/ |
| 45 |
private const UNSUPPORTED = ['image', 'file', 'gallery', 'tab', 'accordion']; |
| 46 |
|
| 47 |
/** |
| 48 |
* Checkout placements and the WooCommerce hook each one prints in. |
| 49 |
*/ |
| 50 |
private const PLACEMENTS = [ |
| 51 |
'before_billing' => 'woocommerce_before_checkout_billing_form', |
| 52 |
'after_billing' => 'woocommerce_after_checkout_billing_form', |
| 53 |
'before_shipping' => 'woocommerce_before_checkout_shipping_form', |
| 54 |
'after_shipping' => 'woocommerce_after_checkout_shipping_form', |
| 55 |
'before_order' => 'woocommerce_before_order_notes', |
| 56 |
'after_order' => 'woocommerce_after_order_notes', |
| 57 |
]; |
| 58 |
|
| 59 |
/** |
| 60 |
* Order being written by save_checkout(), saved once at the end. |
| 61 |
* |
| 62 |
* @var \WC_Order|null |
| 63 |
*/ |
| 64 |
private static $active_order = null; |
| 65 |
|
| 66 |
/** |
| 67 |
* True while an account form writes to the customer's profile. |
| 68 |
* |
| 69 |
* @var bool |
| 70 |
*/ |
| 71 |
private static $saving_user = false; |
| 72 |
|
| 73 |
/** |
| 74 |
* Id of the account form whose submission failed validation. |
| 75 |
* |
| 76 |
* @var string |
| 77 |
*/ |
| 78 |
private static $account_failed = ''; |
| 79 |
|
| 80 |
/** |
| 81 |
* Messages of that failed submission. |
| 82 |
* |
| 83 |
* @var array<int,string> |
| 84 |
*/ |
| 85 |
private static $account_errors = []; |
| 86 |
|
| 87 |
/** |
| 88 |
* Hook storage, checkout processing, display and the account form. |
| 89 |
* |
| 90 |
* Registered at plugin load: the order is created by ?wc-ajax=checkout, |
| 91 |
* a request in which no Elementor widget renders. |
| 92 |
* |
| 93 |
* @return void |
| 94 |
*/ |
| 95 |
public static function register(): void |
| 96 |
{ |
| 97 |
add_filter('acf/pre_load_metadata', [self::class, 'load_meta'], 5, 4); |
| 98 |
add_filter('acf/pre_update_metadata', [self::class, 'update_meta'], 5, 5); |
| 99 |
add_filter('acf/pre_delete_metadata', [self::class, 'delete_meta'], 5, 4); |
| 100 |
|
| 101 |
add_action('woocommerce_after_checkout_validation', [self::class, 'validate_checkout'], 20, 2); |
| 102 |
add_action('woocommerce_checkout_update_order_meta', [self::class, 'save_checkout'], 20, 1); |
| 103 |
|
| 104 |
add_action('woocommerce_admin_order_data_after_order_details', [self::class, 'print_admin_values']); |
| 105 |
add_action('woocommerce_order_details_after_customer_details', [self::class, 'print_customer_values']); |
| 106 |
// Block themes show the thank-you page with WooCommerce's Order |
| 107 |
// Confirmation blocks, which never run the classic template hook above. |
| 108 |
add_filter('render_block_woocommerce/order-confirmation-totals-wrapper', [self::class, 'append_to_confirmation']); |
| 109 |
add_action('wp_enqueue_scripts', [self::class, 'enqueue_order_view_styles'], 20); |
| 110 |
// The action, not the woocommerce_email_order_meta_fields filter: the |
| 111 |
// filter hands one value to both HTML and plain-text emails and |
| 112 |
// WooCommerce prints it raw in both, so it cannot be escaped for each. |
| 113 |
add_action('woocommerce_email_order_meta', [self::class, 'print_email_values'], 20, 3); |
| 114 |
|
| 115 |
add_action('template_redirect', [self::class, 'handle_account_form']); |
| 116 |
|
| 117 |
add_action('elementor/preview/enqueue_styles', [self::class, 'enqueue_preview_styles']); |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* ACF's field styles in the Elementor preview of checkout and account |
| 122 |
* templates. The editor renders the widgets in its own request and loads |
| 123 |
* the assets there, not in the preview frame, so the preview showed bare |
| 124 |
* browser inputs. |
| 125 |
* |
| 126 |
* @return void |
| 127 |
*/ |
| 128 |
public static function enqueue_preview_styles(): void |
| 129 |
{ |
| 130 |
if (in_array(Context::get_editor_template_type(), ['checkout', 'my_account'], true)) { |
| 131 |
wp_enqueue_style('acf-input'); |
| 132 |
} |
| 133 |
} |
| 134 |
|
| 135 |
/* --------------------------------------------------------------------- |
| 136 |
* Rendering |
| 137 |
* ------------------------------------------------------------------ */ |
| 138 |
|
| 139 |
/** |
| 140 |
* Output of the Checkout ACF Extra Fields widget. |
| 141 |
* |
| 142 |
* The inputs have to be inside WooCommerce's checkout form to travel with |
| 143 |
* the order, so they are printed from the hook the Placement control names |
| 144 |
* rather than where the widget sits. When the form has already been printed |
| 145 |
* above the widget, the block goes out in a hidden carrier and the widget |
| 146 |
* script moves it into the form. |
| 147 |
* |
| 148 |
* @param array<string,mixed> $settings Widget settings for display. |
| 149 |
* @param string $widget_id Elementor element id. |
| 150 |
* |
| 151 |
* @return void |
| 152 |
*/ |
| 153 |
public static function render_checkout(array $settings, string $widget_id): void |
| 154 |
{ |
| 155 |
$editor = Context::is_editor(); |
| 156 |
|
| 157 |
if (!self::acf_ready()) { |
| 158 |
if ($editor) { |
| 159 |
self::print_notice(esc_html__('Install and activate Advanced Custom Fields to use this widget.', 'king-addons')); |
| 160 |
} |
| 161 |
return; |
| 162 |
} |
| 163 |
|
| 164 |
$placement = (string) ($settings['placement'] ?? 'after_order'); |
| 165 |
if (!isset(self::PLACEMENTS[$placement])) { |
| 166 |
$placement = 'after_order'; |
| 167 |
} |
| 168 |
|
| 169 |
$resolved = self::resolve(self::keys_from_settings($settings), 'checkout'); |
| 170 |
$heading = trim((string) ($settings['heading'] ?? '')); |
| 171 |
$classes = self::block_classes('ka-woo-checkout-acf-fields', $widget_id, $settings); |
| 172 |
|
| 173 |
if ($editor) { |
| 174 |
self::print_preview('ka-woo-checkout-acf-fields', $classes, $heading, $resolved, sprintf( |
| 175 |
/* translators: %s: where the fields go, e.g. "after order notes". */ |
| 176 |
esc_html__('Shown inside the checkout form, %s. Values are saved to the order.', 'king-addons'), |
| 177 |
self::placement_label($placement) |
| 178 |
)); |
| 179 |
return; |
| 180 |
} |
| 181 |
|
| 182 |
if (!$resolved['fields']) { |
| 183 |
return; |
| 184 |
} |
| 185 |
|
| 186 |
// WooCommerce prints the shipping form only for carts that need a |
| 187 |
// shipping address; its hooks never fire otherwise. |
| 188 |
if (in_array($placement, ['before_shipping', 'after_shipping'], true) |
| 189 |
&& function_exists('WC') && WC()->cart && !WC()->cart->needs_shipping_address()) { |
| 190 |
$placement = 'after_order'; |
| 191 |
} |
| 192 |
|
| 193 |
$token = self::sign([ |
| 194 |
'c' => 'checkout', |
| 195 |
'k' => array_keys($resolved['fields']), |
| 196 |
'p' => $placement, |
| 197 |
'h' => $heading, |
| 198 |
]); |
| 199 |
|
| 200 |
$block = self::checkout_block($classes, $heading, $resolved['fields'], $token, $placement); |
| 201 |
$hook = self::PLACEMENTS[$placement]; |
| 202 |
|
| 203 |
// The checkout form widget often sits above this one. Once that form |
| 204 |
// has printed, adding the placement hook is too late - the hook already |
| 205 |
// ran, or (order notes disabled) it never will. Hand the block to JS |
| 206 |
// in a carrier instead. |
| 207 |
$form_already_printed = did_action('woocommerce_before_checkout_form') > 0 |
| 208 |
|| did_action('woocommerce_after_checkout_form') > 0 |
| 209 |
|| did_action('woocommerce_checkout_order_review') > 0; |
| 210 |
|
| 211 |
if (!$form_already_printed && !did_action($hook)) { |
| 212 |
$printed = false; |
| 213 |
add_action($hook, static function () use ($block, &$printed): void { |
| 214 |
// A page can hold a second checkout form; the fields belong to one. |
| 215 |
if ($printed) { |
| 216 |
return; |
| 217 |
} |
| 218 |
$printed = true; |
| 219 |
echo $block; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts and ACF's own renderer. |
| 220 |
}, 20); |
| 221 |
return; |
| 222 |
} |
| 223 |
|
| 224 |
echo '<div class="ka-woo-checkout-acf-fields-carrier" data-ka-acf-placement="' . esc_attr($placement) . '" hidden>'; |
| 225 |
echo $block; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts and ACF's own renderer. |
| 226 |
echo '</div>'; |
| 227 |
} |
| 228 |
|
| 229 |
/** |
| 230 |
* Output of the My Account ACF Extra Fields widget: a form of its own that |
| 231 |
* saves the fields to the customer's profile. |
| 232 |
* |
| 233 |
* @param array<string,mixed> $settings Widget settings for display. |
| 234 |
* @param string $widget_id Elementor element id. |
| 235 |
* |
| 236 |
* @return void |
| 237 |
*/ |
| 238 |
public static function render_account(array $settings, string $widget_id): void |
| 239 |
{ |
| 240 |
$editor = Context::is_editor(); |
| 241 |
|
| 242 |
if (!self::acf_ready()) { |
| 243 |
if ($editor) { |
| 244 |
self::print_notice(esc_html__('Install and activate Advanced Custom Fields to use this widget.', 'king-addons')); |
| 245 |
} |
| 246 |
return; |
| 247 |
} |
| 248 |
|
| 249 |
$resolved = self::resolve(self::keys_from_settings($settings), 'account'); |
| 250 |
$heading = trim((string) ($settings['heading'] ?? '')); |
| 251 |
$classes = self::block_classes('ka-woo-account-acf-fields', $widget_id, $settings); |
| 252 |
|
| 253 |
if ($editor) { |
| 254 |
self::print_preview('ka-woo-account-acf-fields', $classes, $heading, $resolved, esc_html__('Customers fill these in and save them to their profile with the button below the fields.', 'king-addons')); |
| 255 |
return; |
| 256 |
} |
| 257 |
|
| 258 |
if (!is_user_logged_in() || !$resolved['fields']) { |
| 259 |
return; |
| 260 |
} |
| 261 |
|
| 262 |
$keys = array_keys($resolved['fields']); |
| 263 |
$form_id = substr(md5($widget_id . '|' . implode(',', $keys)), 0, 12); |
| 264 |
$failed = self::$account_failed === $form_id; |
| 265 |
$user_id = 'user_' . get_current_user_id(); |
| 266 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only compared, never stored. |
| 267 |
$saved = isset($_GET['ka-acf-saved']) && $form_id === sanitize_key(wp_unslash($_GET['ka-acf-saved'])); |
| 268 |
|
| 269 |
self::enqueue_acf(); |
| 270 |
|
| 271 |
echo '<div class="' . esc_attr($classes) . '">'; |
| 272 |
if ('' !== $heading) { |
| 273 |
echo '<h3 class="ka-woo-account-acf-fields__heading">' . esc_html($heading) . '</h3>'; |
| 274 |
} |
| 275 |
|
| 276 |
if ($failed && self::$account_errors) { |
| 277 |
echo '<ul class="woocommerce-error" role="alert">'; |
| 278 |
foreach (self::$account_errors as $message) { |
| 279 |
echo '<li>' . wp_kses_post($message) . '</li>'; |
| 280 |
} |
| 281 |
echo '</ul>'; |
| 282 |
} elseif ($saved) { |
| 283 |
echo '<div class="woocommerce-message" role="status">' . esc_html__('Your details have been saved.', 'king-addons') . '</div>'; |
| 284 |
} |
| 285 |
|
| 286 |
echo '<form method="post" class="ka-woo-account-acf-fields__form" action="' . esc_url(remove_query_arg('ka-acf-saved')) . '">'; |
| 287 |
echo '<div class="acf-fields ka-acf-fields">'; |
| 288 |
foreach ($resolved['fields'] as $field) { |
| 289 |
// After a failed submission show what the customer typed, not the stored value. |
| 290 |
$value = $failed ? self::posted_value($field) : acf_get_value($user_id, $field); |
| 291 |
self::render_field($field, $value); |
| 292 |
} |
| 293 |
echo '</div>'; |
| 294 |
|
| 295 |
/** |
| 296 |
* Print extra markup inside the My Account fields form. Inputs added |
| 297 |
* here are submitted with it but not stored by King Addons. |
| 298 |
*/ |
| 299 |
do_action('king_addons_my_account_acf_fields'); |
| 300 |
|
| 301 |
wp_nonce_field('ka_acf_account_' . $form_id, '_ka_acf_nonce'); |
| 302 |
echo '<input type="hidden" name="' . esc_attr(self::ACCOUNT_INPUT) . '" value="' . esc_attr(self::sign(['c' => 'account', 'k' => $keys, 'f' => $form_id])) . '" />'; |
| 303 |
|
| 304 |
$button_class = function_exists('wc_wp_theme_get_element_class_name') ? wc_wp_theme_get_element_class_name('button') : ''; |
| 305 |
echo '<p class="ka-woo-account-acf-fields__actions">'; |
| 306 |
echo '<button type="submit" class="woocommerce-Button button' . ($button_class ? ' ' . esc_attr($button_class) : '') . '" name="ka_acf_account_save" value="1">' . esc_html__('Save changes', 'king-addons') . '</button>'; |
| 307 |
echo '</p>'; |
| 308 |
echo '</form>'; |
| 309 |
echo '</div>'; |
| 310 |
} |
| 311 |
|
| 312 |
/** |
| 313 |
* Field keys chosen in a widget: the Pro repeater plus the free text control. |
| 314 |
* |
| 315 |
* @param array<string,mixed> $settings Widget settings. |
| 316 |
* |
| 317 |
* @return array<int,string> |
| 318 |
*/ |
| 319 |
public static function keys_from_settings(array $settings): array |
| 320 |
{ |
| 321 |
$keys = []; |
| 322 |
|
| 323 |
foreach ((array) ($settings['acf_fields'] ?? []) as $row) { |
| 324 |
$key = is_array($row) ? trim((string) ($row['field_key'] ?? '')) : ''; |
| 325 |
if ('' !== $key) { |
| 326 |
$keys[] = $key; |
| 327 |
} |
| 328 |
} |
| 329 |
|
| 330 |
foreach (explode(',', (string) ($settings['field_keys'] ?? '')) as $key) { |
| 331 |
$key = trim($key); |
| 332 |
if ('' !== $key) { |
| 333 |
$keys[] = $key; |
| 334 |
} |
| 335 |
} |
| 336 |
|
| 337 |
return array_values(array_unique($keys)); |
| 338 |
} |
| 339 |
|
| 340 |
/** |
| 341 |
* Classes of a field block. The per-widget class lets the style controls |
| 342 |
* reach the block after it has been printed inside the checkout form, |
| 343 |
* outside the widget's own wrapper. |
| 344 |
* |
| 345 |
* @param string $base Base class. |
| 346 |
* @param string $widget_id Elementor element id. |
| 347 |
* @param array<string,mixed> $settings Widget settings. |
| 348 |
* |
| 349 |
* @return string |
| 350 |
*/ |
| 351 |
private static function block_classes(string $base, string $widget_id, array $settings): string |
| 352 |
{ |
| 353 |
$classes = [$base, 'ka-acf-block', 'ka-acf-block-' . sanitize_html_class($widget_id)]; |
| 354 |
|
| 355 |
if ('yes' !== ($settings['required_notice'] ?? 'yes')) { |
| 356 |
$classes[] = 'ka-acf-no-required-mark'; |
| 357 |
} |
| 358 |
|
| 359 |
return implode(' ', $classes); |
| 360 |
} |
| 361 |
|
| 362 |
/** |
| 363 |
* The checkout block as HTML. |
| 364 |
* |
| 365 |
* @param string $classes Block classes. |
| 366 |
* @param string $heading Heading text. |
| 367 |
* @param array<string,array<string,mixed>> $fields Resolved fields. |
| 368 |
* @param string $token Signed field list. |
| 369 |
* @param string $placement Placement key. |
| 370 |
* |
| 371 |
* @return string |
| 372 |
*/ |
| 373 |
private static function checkout_block(string $classes, string $heading, array $fields, string $token, string $placement): string |
| 374 |
{ |
| 375 |
self::enqueue_acf(); |
| 376 |
|
| 377 |
ob_start(); |
| 378 |
echo '<div class="' . esc_attr($classes) . '" data-ka-acf-placement="' . esc_attr($placement) . '">'; |
| 379 |
if ('' !== $heading) { |
| 380 |
echo '<h3 class="ka-woo-checkout-acf-fields__heading">' . esc_html($heading) . '</h3>'; |
| 381 |
} |
| 382 |
echo '<div class="acf-fields ka-acf-fields">'; |
| 383 |
foreach ($fields as $field) { |
| 384 |
self::render_field($field, $field['default_value'] ?? ''); |
| 385 |
} |
| 386 |
echo '</div>'; |
| 387 |
|
| 388 |
/** |
| 389 |
* Print extra markup inside the checkout block, which sits in the |
| 390 |
* checkout form. Inputs added here are submitted with the order but not |
| 391 |
* stored by King Addons. |
| 392 |
* |
| 393 |
* @param array<int,string> $keys ACF field keys shown. |
| 394 |
* @param bool $required Kept for compatibility; always true. |
| 395 |
* @param string $placement Placement key. |
| 396 |
*/ |
| 397 |
do_action('king_addons_checkout_acf_fields', array_keys($fields), true, $placement); |
| 398 |
echo '<input type="hidden" name="' . esc_attr(self::CHECKOUT_INPUT) . '[]" value="' . esc_attr($token) . '" />'; |
| 399 |
echo '</div>'; |
| 400 |
|
| 401 |
return (string) ob_get_clean(); |
| 402 |
} |
| 403 |
|
| 404 |
/** |
| 405 |
* Print one field with ACF's renderer under the acf[field_key] input name. |
| 406 |
* |
| 407 |
* @param array<string,mixed> $field ACF field. |
| 408 |
* @param mixed $value Value to show. |
| 409 |
* |
| 410 |
* @return void |
| 411 |
*/ |
| 412 |
private static function render_field(array $field, $value): void |
| 413 |
{ |
| 414 |
$field = self::with_woo_classes($field); |
| 415 |
$field['value'] = $value; |
| 416 |
$field['prefix'] = 'acf'; |
| 417 |
|
| 418 |
ob_start(); |
| 419 |
acf_render_field_wrap($field); |
| 420 |
$html = (string) ob_get_clean(); |
| 421 |
|
| 422 |
// Date and time pickers ignore the field's class setting and print |
| 423 |
// their visible input with a fixed class="input". |
| 424 |
echo str_replace('<input type="text" class="input"', '<input type="text" class="input input-text"', $html); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- ACF's own escaped markup. |
| 425 |
} |
| 426 |
|
| 427 |
/** |
| 428 |
* Give a field WooCommerce's row and input classes, so the theme styles it |
| 429 |
* like the checkout and account fields around it instead of ACF's admin look. |
| 430 |
* |
| 431 |
* @param array<string,mixed> $field ACF field. |
| 432 |
* |
| 433 |
* @return array<string,mixed> |
| 434 |
*/ |
| 435 |
private static function with_woo_classes(array $field): array |
| 436 |
{ |
| 437 |
$wrapper = is_array($field['wrapper'] ?? null) ? $field['wrapper'] : []; |
| 438 |
$wrapper['class'] = trim((string) ($wrapper['class'] ?? '') . ' form-row form-row-wide'); |
| 439 |
$field['wrapper'] = $wrapper; |
| 440 |
|
| 441 |
if (in_array($field['type'] ?? '', ['text', 'email', 'url', 'number', 'password', 'textarea'], true)) { |
| 442 |
$field['class'] = trim((string) ($field['class'] ?? '') . ' input-text'); |
| 443 |
} |
| 444 |
|
| 445 |
if (!empty($field['sub_fields']) && is_array($field['sub_fields'])) { |
| 446 |
$field['sub_fields'] = array_map([self::class, 'with_woo_classes'], $field['sub_fields']); |
| 447 |
} |
| 448 |
|
| 449 |
return $field; |
| 450 |
} |
| 451 |
|
| 452 |
/** |
| 453 |
* Editor preview: the fields where the widget sits, plus where they go. |
| 454 |
* |
| 455 |
* @param string $base Base class of the block. |
| 456 |
* @param string $classes Block classes. |
| 457 |
* @param string $heading Heading text. |
| 458 |
* @param array{fields:array<string,array<string,mixed>>,skipped:array<int,string>} $resolved Resolved fields. |
| 459 |
* @param string $note Escaped explanation. |
| 460 |
* |
| 461 |
* @return void |
| 462 |
*/ |
| 463 |
private static function print_preview(string $base, string $classes, string $heading, array $resolved, string $note): void |
| 464 |
{ |
| 465 |
self::enqueue_acf(); |
| 466 |
|
| 467 |
echo '<div class="' . esc_attr($classes) . ' ka-acf-preview">'; |
| 468 |
if ('' !== $heading) { |
| 469 |
echo '<h3 class="' . esc_attr($base . '__heading') . '">' . esc_html($heading) . '</h3>'; |
| 470 |
} |
| 471 |
|
| 472 |
if (!$resolved['fields'] && !$resolved['skipped']) { |
| 473 |
self::print_notice(esc_html__('Choose ACF fields in the ACF Fields section.', 'king-addons')); |
| 474 |
} |
| 475 |
|
| 476 |
if ($resolved['fields']) { |
| 477 |
echo '<div class="acf-fields ka-acf-fields">'; |
| 478 |
foreach ($resolved['fields'] as $field) { |
| 479 |
self::render_field($field, $field['default_value'] ?? ''); |
| 480 |
} |
| 481 |
echo '</div>'; |
| 482 |
echo '<p class="ka-acf-preview__note">' . $note . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by the caller. |
| 483 |
} |
| 484 |
|
| 485 |
foreach ($resolved['skipped'] as $message) { |
| 486 |
self::print_notice(esc_html($message)); |
| 487 |
} |
| 488 |
|
| 489 |
echo '</div>'; |
| 490 |
} |
| 491 |
|
| 492 |
/** |
| 493 |
* Print an editor notice. |
| 494 |
* |
| 495 |
* @param string $message Escaped message. |
| 496 |
* |
| 497 |
* @return void |
| 498 |
*/ |
| 499 |
private static function print_notice(string $message): void |
| 500 |
{ |
| 501 |
echo '<div class="king-addons-woo-builder-notice">' . $message . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by the caller. |
| 502 |
} |
| 503 |
|
| 504 |
/** |
| 505 |
* Human wording of a placement. |
| 506 |
* |
| 507 |
* @param string $placement Placement key. |
| 508 |
* |
| 509 |
* @return string Escaped text. |
| 510 |
*/ |
| 511 |
private static function placement_label(string $placement): string |
| 512 |
{ |
| 513 |
$labels = [ |
| 514 |
'before_billing' => esc_html__('before the billing fields', 'king-addons'), |
| 515 |
'after_billing' => esc_html__('after the billing fields', 'king-addons'), |
| 516 |
'before_shipping' => esc_html__('before the shipping fields', 'king-addons'), |
| 517 |
'after_shipping' => esc_html__('after the shipping fields', 'king-addons'), |
| 518 |
'before_order' => esc_html__('before the order notes', 'king-addons'), |
| 519 |
'after_order' => esc_html__('after the order notes', 'king-addons'), |
| 520 |
]; |
| 521 |
|
| 522 |
return $labels[$placement] ?? $labels['after_order']; |
| 523 |
} |
| 524 |
|
| 525 |
/** |
| 526 |
* ACF's scripts and styles are admin-only by default; without them a select, |
| 527 |
* a date picker or conditional logic is dead markup on the storefront. |
| 528 |
* |
| 529 |
* @return void |
| 530 |
*/ |
| 531 |
private static function enqueue_acf(): void |
| 532 |
{ |
| 533 |
static $done = false; |
| 534 |
|
| 535 |
if ($done || !function_exists('acf_enqueue_scripts')) { |
| 536 |
return; |
| 537 |
} |
| 538 |
$done = true; |
| 539 |
|
| 540 |
acf_enqueue_scripts(); |
| 541 |
|
| 542 |
// ACF asks "Leave site?" when a page with changed fields is left without |
| 543 |
// it seeing the form submit. WooCommerce submits the checkout over AJAX |
| 544 |
// and stops that event, so the dialog met the customer on the way to |
| 545 |
// the thank-you page. It is an admin safeguard; a shop has no use for it. |
| 546 |
wp_add_inline_script('acf-input', 'if(window.acf&&acf.unload){acf.unload.disable();}'); |
| 547 |
} |
| 548 |
|
| 549 |
/* --------------------------------------------------------------------- |
| 550 |
* Fields |
| 551 |
* ------------------------------------------------------------------ */ |
| 552 |
|
| 553 |
/** |
| 554 |
* Whether the ACF functions this class relies on are loaded. |
| 555 |
* |
| 556 |
* @return bool |
| 557 |
*/ |
| 558 |
private static function acf_ready(): bool |
| 559 |
{ |
| 560 |
return function_exists('acf_get_field') |
| 561 |
&& function_exists('acf_render_field_wrap') |
| 562 |
&& function_exists('acf_validate_value') |
| 563 |
&& function_exists('acf_update_value') |
| 564 |
&& function_exists('acf_get_value'); |
| 565 |
} |
| 566 |
|
| 567 |
/** |
| 568 |
* Turn configured keys or names into ACF fields that can be used here. |
| 569 |
* |
| 570 |
* @param array<int,string> $keys Field keys or names. |
| 571 |
* @param string $context "checkout" or "account". |
| 572 |
* |
| 573 |
* @return array{fields:array<string,array<string,mixed>>,skipped:array<int,string>} |
| 574 |
*/ |
| 575 |
private static function resolve(array $keys, string $context): array |
| 576 |
{ |
| 577 |
$fields = []; |
| 578 |
$skipped = []; |
| 579 |
|
| 580 |
foreach ($keys as $key) { |
| 581 |
$field = acf_get_field($key); |
| 582 |
if (!$field || empty($field['key'])) { |
| 583 |
/* translators: %s: ACF field key or name. */ |
| 584 |
$skipped[] = sprintf(__('%s: there is no ACF field with this key or name.', 'king-addons'), $key); |
| 585 |
continue; |
| 586 |
} |
| 587 |
|
| 588 |
$label = '' !== (string) ($field['label'] ?? '') ? (string) $field['label'] : (string) $field['name']; |
| 589 |
|
| 590 |
if (in_array($field['type'], self::UNSUPPORTED, true)) { |
| 591 |
/* translators: 1: field label, 2: ACF field type. */ |
| 592 |
$skipped[] = sprintf(__('%1$s: "%2$s" fields cannot be filled in here and are left out.', 'king-addons'), $label, $field['type']); |
| 593 |
continue; |
| 594 |
} |
| 595 |
|
| 596 |
if (!self::storable($field, $context)) { |
| 597 |
/* translators: %s: field label. */ |
| 598 |
$skipped[] = sprintf(__('%s: the field name is already used by WooCommerce or WordPress for this record, so it is left out. Rename the field in ACF.', 'king-addons'), $label); |
| 599 |
continue; |
| 600 |
} |
| 601 |
|
| 602 |
$fields[$field['key']] = $field; |
| 603 |
} |
| 604 |
|
| 605 |
return ['fields' => $fields, 'skipped' => $skipped]; |
| 606 |
} |
| 607 |
|
| 608 |
/** |
| 609 |
* Fields named in a verified token, re-checked against the live ACF setup. |
| 610 |
* |
| 611 |
* @param array<int,mixed> $keys Field keys from the token. |
| 612 |
* @param string $context "checkout" or "account". |
| 613 |
* |
| 614 |
* @return array<string,array<string,mixed>> |
| 615 |
*/ |
| 616 |
private static function token_fields(array $keys, string $context): array |
| 617 |
{ |
| 618 |
return self::resolve(array_map('strval', $keys), $context)['fields']; |
| 619 |
} |
| 620 |
|
| 621 |
/** |
| 622 |
* Whether a field can be stored without touching data the record keeps for |
| 623 |
* itself. ACF writes the value under the field name and a reference under |
| 624 |
* "_name"; on an order WooCommerce turns its own keys into setter calls, so a |
| 625 |
* field called "order_total" would rewrite the order total. |
| 626 |
* |
| 627 |
* @param array<string,mixed> $field ACF field. |
| 628 |
* @param string $context "checkout" or "account". |
| 629 |
* @param string $prefix Name prefix of a parent group. |
| 630 |
* |
| 631 |
* @return bool |
| 632 |
*/ |
| 633 |
private static function storable(array $field, string $context, string $prefix = ''): bool |
| 634 |
{ |
| 635 |
$name = $prefix . (string) ($field['name'] ?? ''); |
| 636 |
if ('' === $name || '_' === $name[0]) { |
| 637 |
return false; |
| 638 |
} |
| 639 |
|
| 640 |
$reserved = 'checkout' === $context |
| 641 |
? self::is_reserved_order_key($name) || self::is_reserved_order_key('_' . $name) |
| 642 |
: self::is_reserved_user_key($name); |
| 643 |
|
| 644 |
if ($reserved) { |
| 645 |
return false; |
| 646 |
} |
| 647 |
|
| 648 |
if ('group' === ($field['type'] ?? '') && !empty($field['sub_fields'])) { |
| 649 |
foreach ((array) $field['sub_fields'] as $sub_field) { |
| 650 |
if (is_array($sub_field) && !self::storable($sub_field, $context, $name . '_')) { |
| 651 |
return false; |
| 652 |
} |
| 653 |
} |
| 654 |
} |
| 655 |
|
| 656 |
return true; |
| 657 |
} |
| 658 |
|
| 659 |
/** |
| 660 |
* Order meta keys WooCommerce keeps for itself. |
| 661 |
* |
| 662 |
* @param string $key Meta key. |
| 663 |
* |
| 664 |
* @return bool |
| 665 |
*/ |
| 666 |
private static function is_reserved_order_key(string $key): bool |
| 667 |
{ |
| 668 |
static $internal = null; |
| 669 |
|
| 670 |
if (null === $internal) { |
| 671 |
$internal = []; |
| 672 |
if (class_exists('WC_Order')) { |
| 673 |
try { |
| 674 |
$internal = (array) (new \WC_Order())->get_data_store()->get_internal_meta_keys(); |
| 675 |
} catch (\Throwable $e) { |
| 676 |
$internal = []; |
| 677 |
} |
| 678 |
} |
| 679 |
} |
| 680 |
|
| 681 |
return self::ORDER_META === $key || in_array($key, $internal, true); |
| 682 |
} |
| 683 |
|
| 684 |
/** |
| 685 |
* User meta keys a customer must not be able to write: roles and |
| 686 |
* capabilities live under the table prefix, sessions under session_tokens. |
| 687 |
* |
| 688 |
* @param string $key Meta key. |
| 689 |
* |
| 690 |
* @return bool |
| 691 |
*/ |
| 692 |
private static function is_reserved_user_key(string $key): bool |
| 693 |
{ |
| 694 |
global $wpdb; |
| 695 |
|
| 696 |
if ('' === $key || '_' === $key[0] || 'session_tokens' === $key) { |
| 697 |
return true; |
| 698 |
} |
| 699 |
|
| 700 |
return 0 === strpos($key, $wpdb->base_prefix); |
| 701 |
} |
| 702 |
|
| 703 |
/* --------------------------------------------------------------------- |
| 704 |
* Signed field lists |
| 705 |
* ------------------------------------------------------------------ */ |
| 706 |
|
| 707 |
/** |
| 708 |
* Sign a field list so a submission can only write the fields the page |
| 709 |
* actually showed, whatever else is posted under acf[...]. |
| 710 |
* |
| 711 |
* @param array<string,mixed> $payload Data to sign. |
| 712 |
* |
| 713 |
* @return string |
| 714 |
*/ |
| 715 |
private static function sign(array $payload): string |
| 716 |
{ |
| 717 |
$data = rtrim(strtr(base64_encode((string) wp_json_encode($payload)), '+/', '-_'), '='); |
| 718 |
|
| 719 |
return $data . '.' . hash_hmac('sha256', $data, wp_salt('nonce')); |
| 720 |
} |
| 721 |
|
| 722 |
/** |
| 723 |
* Verify a signed field list. |
| 724 |
* |
| 725 |
* @param mixed $token Posted token. |
| 726 |
* @param string $context Expected context. |
| 727 |
* |
| 728 |
* @return array<string,mixed>|null |
| 729 |
*/ |
| 730 |
private static function verify($token, string $context): ?array |
| 731 |
{ |
| 732 |
if (!is_string($token) || false === strpos($token, '.')) { |
| 733 |
return null; |
| 734 |
} |
| 735 |
|
| 736 |
[$data, $mac] = explode('.', $token, 2); |
| 737 |
if (!hash_equals(hash_hmac('sha256', $data, wp_salt('nonce')), $mac)) { |
| 738 |
return null; |
| 739 |
} |
| 740 |
|
| 741 |
$payload = json_decode((string) base64_decode(strtr($data, '-_', '+/')), true); |
| 742 |
if (!is_array($payload) || ($payload['c'] ?? '') !== $context || empty($payload['k']) || !is_array($payload['k'])) { |
| 743 |
return null; |
| 744 |
} |
| 745 |
|
| 746 |
return $payload; |
| 747 |
} |
| 748 |
|
| 749 |
/** |
| 750 |
* Verified checkout blocks of the current submission. |
| 751 |
* |
| 752 |
* @return array<int,array<string,mixed>> |
| 753 |
*/ |
| 754 |
private static function posted_checkout_blocks(): array |
| 755 |
{ |
| 756 |
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce verified the checkout nonce before these hooks run. |
| 757 |
$tokens = isset($_POST[self::CHECKOUT_INPUT]) ? (array) wp_unslash($_POST[self::CHECKOUT_INPUT]) : []; |
| 758 |
$blocks = []; |
| 759 |
|
| 760 |
foreach ($tokens as $token) { |
| 761 |
$payload = self::verify($token, 'checkout'); |
| 762 |
if (null === $payload) { |
| 763 |
continue; |
| 764 |
} |
| 765 |
|
| 766 |
// Fields placed in the shipping form are hidden with it when the |
| 767 |
// order ships to the billing address; WooCommerce ignores its own |
| 768 |
// shipping fields then, and so do we. |
| 769 |
$placement = (string) ($payload['p'] ?? ''); |
| 770 |
// phpcs:ignore WordPress.Security.NonceVerification.Missing |
| 771 |
if (in_array($placement, ['before_shipping', 'after_shipping'], true) && empty($_POST['ship_to_different_address'])) { |
| 772 |
continue; |
| 773 |
} |
| 774 |
|
| 775 |
$blocks[] = $payload; |
| 776 |
} |
| 777 |
|
| 778 |
return $blocks; |
| 779 |
} |
| 780 |
|
| 781 |
/** |
| 782 |
* The submitted value of a field, or null when the browser sent nothing - |
| 783 |
* ACF disables the inputs of a field its conditional logic hides. |
| 784 |
* |
| 785 |
* @param array<string,mixed> $field ACF field. |
| 786 |
* @param bool $for_storage Prepare for acf_update_value(): strip |
| 787 |
* HTML the visitor may not post, as |
| 788 |
* ACF's own front-end forms do, and |
| 789 |
* slash it the way WordPress meta |
| 790 |
* functions expect. |
| 791 |
* |
| 792 |
* @return mixed |
| 793 |
*/ |
| 794 |
private static function posted_value(array $field, bool $for_storage = false) |
| 795 |
{ |
| 796 |
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- the callers verify the request. |
| 797 |
if (!isset($_POST['acf']) || !is_array($_POST['acf']) || !array_key_exists($field['key'], $_POST['acf'])) { |
| 798 |
return null; |
| 799 |
} |
| 800 |
|
| 801 |
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised below per field type by ACF. |
| 802 |
$value = wp_unslash($_POST['acf'][$field['key']]); |
| 803 |
|
| 804 |
if (!$for_storage) { |
| 805 |
return $value; |
| 806 |
} |
| 807 |
|
| 808 |
if (!current_user_can('unfiltered_html')) { |
| 809 |
$value = wp_kses_post_deep($value); |
| 810 |
} |
| 811 |
|
| 812 |
return wp_slash($value); |
| 813 |
} |
| 814 |
|
| 815 |
/** |
| 816 |
* Validate the given fields with ACF's own rules. |
| 817 |
* |
| 818 |
* @param array<string,array<string,mixed>> $fields Fields to validate. |
| 819 |
* |
| 820 |
* @return array<int,array{input:string,message:string}> |
| 821 |
*/ |
| 822 |
private static function validate(array $fields): array |
| 823 |
{ |
| 824 |
acf_reset_validation_errors(); |
| 825 |
|
| 826 |
foreach ($fields as $field) { |
| 827 |
$value = self::posted_value($field); |
| 828 |
if (null === $value && !empty($field['conditional_logic'])) { |
| 829 |
continue; |
| 830 |
} |
| 831 |
|
| 832 |
$input = 'acf[' . $field['key'] . ']'; |
| 833 |
if (acf_validate_value($value ?? '', $field, $input) && !self::is_offered_choice($field, $value)) { |
| 834 |
/* translators: %s: field label. */ |
| 835 |
acf_add_validation_error($input, sprintf(__('%s: choose one of the offered options.', 'king-addons'), $field['label'])); |
| 836 |
} |
| 837 |
} |
| 838 |
|
| 839 |
// ACF returns false, not an empty array, when there is nothing to report. |
| 840 |
$errors = acf_get_validation_errors(); |
| 841 |
acf_reset_validation_errors(); |
| 842 |
|
| 843 |
return is_array($errors) ? array_values(array_filter($errors, 'is_array')) : []; |
| 844 |
} |
| 845 |
|
| 846 |
/** |
| 847 |
* Whether a choice field got only choices it offers. ACF itself stores |
| 848 |
* whatever a crafted request sends, and the shop would see it on the order. |
| 849 |
* |
| 850 |
* @param array<string,mixed> $field ACF field. |
| 851 |
* @param mixed $value Submitted value. |
| 852 |
* |
| 853 |
* @return bool |
| 854 |
*/ |
| 855 |
private static function is_offered_choice(array $field, $value): bool |
| 856 |
{ |
| 857 |
if (!in_array($field['type'] ?? '', ['select', 'radio', 'button_group', 'checkbox'], true) |
| 858 |
|| !empty($field['allow_custom']) || !empty($field['other_choice'])) { |
| 859 |
return true; |
| 860 |
} |
| 861 |
|
| 862 |
$choices = array_map('strval', array_keys((array) ($field['choices'] ?? []))); |
| 863 |
|
| 864 |
foreach ((array) $value as $item) { |
| 865 |
if (!is_scalar($item) || ('' !== (string) $item && !in_array((string) $item, $choices, true))) { |
| 866 |
return false; |
| 867 |
} |
| 868 |
} |
| 869 |
|
| 870 |
return true; |
| 871 |
} |
| 872 |
|
| 873 |
/* --------------------------------------------------------------------- |
| 874 |
* Checkout |
| 875 |
* ------------------------------------------------------------------ */ |
| 876 |
|
| 877 |
/** |
| 878 |
* Report invalid fields the way WooCommerce reports its own. |
| 879 |
* |
| 880 |
* @param array<string,mixed> $data Posted checkout data. |
| 881 |
* @param \WP_Error $errors Checkout errors. |
| 882 |
* |
| 883 |
* @return void |
| 884 |
*/ |
| 885 |
public static function validate_checkout($data, $errors): void |
| 886 |
{ |
| 887 |
if (!($errors instanceof \WP_Error) || !self::acf_ready()) { |
| 888 |
return; |
| 889 |
} |
| 890 |
|
| 891 |
$fields = []; |
| 892 |
foreach (self::posted_checkout_blocks() as $block) { |
| 893 |
$fields += self::token_fields($block['k'], 'checkout'); |
| 894 |
} |
| 895 |
|
| 896 |
if (!$fields) { |
| 897 |
return; |
| 898 |
} |
| 899 |
|
| 900 |
foreach (self::validate($fields) as $error) { |
| 901 |
$input = (string) ($error['input'] ?? ''); |
| 902 |
$id = function_exists('acf_idify') ? acf_idify($input) : sanitize_key($input); |
| 903 |
|
| 904 |
// One code per field: WP_Error keeps one data array per code, and |
| 905 |
// WooCommerce links each notice to its field through that data. |
| 906 |
$errors->add( |
| 907 |
'ka_acf_' . $id, |
| 908 |
wp_kses_post((string) ($error['message'] ?? '')), |
| 909 |
['id' => $id] |
| 910 |
); |
| 911 |
} |
| 912 |
} |
| 913 |
|
| 914 |
/** |
| 915 |
* Store the fields on the order WooCommerce just created. |
| 916 |
* |
| 917 |
* @param int $order_id Order id. |
| 918 |
* |
| 919 |
* @return void |
| 920 |
*/ |
| 921 |
public static function save_checkout($order_id): void |
| 922 |
{ |
| 923 |
if (!self::acf_ready() || !function_exists('wc_get_order')) { |
| 924 |
return; |
| 925 |
} |
| 926 |
|
| 927 |
$blocks = self::posted_checkout_blocks(); |
| 928 |
if (!$blocks) { |
| 929 |
return; |
| 930 |
} |
| 931 |
|
| 932 |
$order = wc_get_order($order_id); |
| 933 |
if (!$order) { |
| 934 |
return; |
| 935 |
} |
| 936 |
|
| 937 |
$record = []; |
| 938 |
self::$active_order = $order; |
| 939 |
|
| 940 |
try { |
| 941 |
foreach ($blocks as $block) { |
| 942 |
$stored = []; |
| 943 |
foreach (self::token_fields($block['k'], 'checkout') as $field) { |
| 944 |
$value = self::posted_value($field, true); |
| 945 |
if (null === $value) { |
| 946 |
if (!empty($field['conditional_logic'])) { |
| 947 |
continue; |
| 948 |
} |
| 949 |
$value = ''; |
| 950 |
} |
| 951 |
|
| 952 |
acf_update_value($value, 'woo_order_' . $order->get_id(), $field); |
| 953 |
$stored[] = ['key' => $field['key'], 'name' => $field['name'], 'label' => $field['label']]; |
| 954 |
} |
| 955 |
|
| 956 |
if ($stored) { |
| 957 |
$record[] = ['heading' => (string) ($block['h'] ?? ''), 'fields' => $stored]; |
| 958 |
} |
| 959 |
} |
| 960 |
} finally { |
| 961 |
self::$active_order = null; |
| 962 |
} |
| 963 |
|
| 964 |
if ($record) { |
| 965 |
$order->update_meta_data(self::ORDER_META, $record); |
| 966 |
} |
| 967 |
$order->save(); |
| 968 |
} |
| 969 |
|
| 970 |
/* --------------------------------------------------------------------- |
| 971 |
* My Account |
| 972 |
* ------------------------------------------------------------------ */ |
| 973 |
|
| 974 |
/** |
| 975 |
* Save a submitted account form, then redirect so a reload does not post |
| 976 |
* it again. A failed submission renders the form again with the errors. |
| 977 |
* |
| 978 |
* @return void |
| 979 |
*/ |
| 980 |
public static function handle_account_form(): void |
| 981 |
{ |
| 982 |
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified below, once the form is known. |
| 983 |
if (empty($_POST['ka_acf_account_save']) || empty($_POST[self::ACCOUNT_INPUT]) || !self::acf_ready()) { |
| 984 |
return; |
| 985 |
} |
| 986 |
|
| 987 |
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a signed token, verified here. |
| 988 |
$payload = self::verify(wp_unslash($_POST[self::ACCOUNT_INPUT]), 'account'); |
| 989 |
if (null === $payload || !is_user_logged_in()) { |
| 990 |
return; |
| 991 |
} |
| 992 |
|
| 993 |
$form_id = sanitize_key((string) ($payload['f'] ?? '')); |
| 994 |
$nonce = isset($_POST['_ka_acf_nonce']) ? sanitize_text_field(wp_unslash($_POST['_ka_acf_nonce'])) : ''; |
| 995 |
|
| 996 |
if (!wp_verify_nonce($nonce, 'ka_acf_account_' . $form_id)) { |
| 997 |
self::$account_failed = $form_id; |
| 998 |
self::$account_errors = [esc_html__('Your session has expired. Please try again.', 'king-addons')]; |
| 999 |
return; |
| 1000 |
} |
| 1001 |
|
| 1002 |
$fields = self::token_fields($payload['k'], 'account'); |
| 1003 |
$errors = self::validate($fields); |
| 1004 |
|
| 1005 |
if ($errors) { |
| 1006 |
self::$account_failed = $form_id; |
| 1007 |
self::$account_errors = array_map(static function ($error): string { |
| 1008 |
return (string) ($error['message'] ?? ''); |
| 1009 |
}, $errors); |
| 1010 |
return; |
| 1011 |
} |
| 1012 |
|
| 1013 |
$user_id = 'user_' . get_current_user_id(); |
| 1014 |
self::$saving_user = true; |
| 1015 |
|
| 1016 |
try { |
| 1017 |
foreach ($fields as $field) { |
| 1018 |
$value = self::posted_value($field, true); |
| 1019 |
if (null === $value) { |
| 1020 |
if (!empty($field['conditional_logic'])) { |
| 1021 |
continue; |
| 1022 |
} |
| 1023 |
$value = ''; |
| 1024 |
} |
| 1025 |
acf_update_value($value, $user_id, $field); |
| 1026 |
} |
| 1027 |
} finally { |
| 1028 |
self::$saving_user = false; |
| 1029 |
} |
| 1030 |
|
| 1031 |
$back = wp_validate_redirect(wp_get_raw_referer(), wc_get_page_permalink('myaccount')); |
| 1032 |
wp_safe_redirect(add_query_arg('ka-acf-saved', $form_id, remove_query_arg('ka-acf-saved', $back))); |
| 1033 |
exit; |
| 1034 |
} |
| 1035 |
|
| 1036 |
/* --------------------------------------------------------------------- |
| 1037 |
* Order storage for ACF |
| 1038 |
* ------------------------------------------------------------------ */ |
| 1039 |
|
| 1040 |
/** |
| 1041 |
* Order id from an ACF post id of the form "woo_order_123". |
| 1042 |
* |
| 1043 |
* @param mixed $post_id ACF post id. |
| 1044 |
* |
| 1045 |
* @return int 0 when it is not an order. |
| 1046 |
*/ |
| 1047 |
private static function order_id($post_id): int |
| 1048 |
{ |
| 1049 |
if (is_string($post_id) && preg_match('/^woo_order_(\d+)$/', $post_id, $match)) { |
| 1050 |
return (int) $match[1]; |
| 1051 |
} |
| 1052 |
|
| 1053 |
return 0; |
| 1054 |
} |
| 1055 |
|
| 1056 |
/** |
| 1057 |
* Whether ACF itself can store order meta (ACF PRO registers a location). |
| 1058 |
* |
| 1059 |
* @return bool |
| 1060 |
*/ |
| 1061 |
private static function acf_has_order_storage(): bool |
| 1062 |
{ |
| 1063 |
static $native = null; |
| 1064 |
|
| 1065 |
if (null === $native) { |
| 1066 |
$native = function_exists('acf_get_meta_instance') && null !== acf_get_meta_instance('woo_order'); |
| 1067 |
} |
| 1068 |
|
| 1069 |
return $native; |
| 1070 |
} |
| 1071 |
|
| 1072 |
/** |
| 1073 |
* The order an ACF call refers to. |
| 1074 |
* |
| 1075 |
* @param int $order_id Order id. |
| 1076 |
* |
| 1077 |
* @return \WC_Order|null |
| 1078 |
*/ |
| 1079 |
private static function order(int $order_id): ?\WC_Order |
| 1080 |
{ |
| 1081 |
if (self::$active_order instanceof \WC_Order && self::$active_order->get_id() === $order_id) { |
| 1082 |
return self::$active_order; |
| 1083 |
} |
| 1084 |
|
| 1085 |
$order = function_exists('wc_get_order') ? wc_get_order($order_id) : null; |
| 1086 |
|
| 1087 |
return $order instanceof \WC_Order ? $order : null; |
| 1088 |
} |
| 1089 |
|
| 1090 |
/** |
| 1091 |
* Read ACF values of an order through WooCommerce, which knows whether |
| 1092 |
* orders live in their own tables (HPOS) or as posts. |
| 1093 |
* |
| 1094 |
* @param mixed $value Short-circuit value. |
| 1095 |
* @param mixed $post_id ACF post id. |
| 1096 |
* @param string $name Meta name. |
| 1097 |
* @param bool $hidden True for the "_name" reference. |
| 1098 |
* |
| 1099 |
* @return mixed |
| 1100 |
*/ |
| 1101 |
public static function load_meta($value, $post_id, $name, $hidden) |
| 1102 |
{ |
| 1103 |
$order_id = self::order_id($post_id); |
| 1104 |
if (null !== $value || !$order_id || self::acf_has_order_storage()) { |
| 1105 |
return $value; |
| 1106 |
} |
| 1107 |
|
| 1108 |
$key = ($hidden ? '_' : '') . $name; |
| 1109 |
$order = self::order($order_id); |
| 1110 |
|
| 1111 |
if (!$order || self::is_reserved_order_key($key) || !$order->meta_exists($key)) { |
| 1112 |
return '__return_null'; |
| 1113 |
} |
| 1114 |
|
| 1115 |
return $order->get_meta($key, true); |
| 1116 |
} |
| 1117 |
|
| 1118 |
/** |
| 1119 |
* Write ACF values of an order through WooCommerce; refuse keys the order |
| 1120 |
* or the user record keeps for itself. |
| 1121 |
* |
| 1122 |
* @param mixed $result Short-circuit result. |
| 1123 |
* @param mixed $post_id ACF post id. |
| 1124 |
* @param string $name Meta name. |
| 1125 |
* @param mixed $value Value, slashed. |
| 1126 |
* @param bool $hidden True for the "_name" reference. |
| 1127 |
* |
| 1128 |
* @return mixed |
| 1129 |
*/ |
| 1130 |
public static function update_meta($result, $post_id, $name, $value, $hidden) |
| 1131 |
{ |
| 1132 |
if (null !== $result) { |
| 1133 |
return $result; |
| 1134 |
} |
| 1135 |
|
| 1136 |
$key = ($hidden ? '_' : '') . $name; |
| 1137 |
|
| 1138 |
if (self::$saving_user && is_string($post_id) && 0 === strpos($post_id, 'user_')) { |
| 1139 |
// References are "_name" by design. Values are checked here as well |
| 1140 |
// as in storable(): a group composes its sub field names, so a |
| 1141 |
// group "session" with a sub field "tokens" writes "session_tokens". |
| 1142 |
return !$hidden && self::is_reserved_user_key($key) ? false : null; |
| 1143 |
} |
| 1144 |
|
| 1145 |
$order_id = self::order_id($post_id); |
| 1146 |
if (!$order_id) { |
| 1147 |
return null; |
| 1148 |
} |
| 1149 |
|
| 1150 |
if (self::is_reserved_order_key($key) || (!$hidden && '_' === substr($key, 0, 1))) { |
| 1151 |
return false; |
| 1152 |
} |
| 1153 |
|
| 1154 |
if (self::acf_has_order_storage()) { |
| 1155 |
return null; |
| 1156 |
} |
| 1157 |
|
| 1158 |
$order = self::order($order_id); |
| 1159 |
if (!$order) { |
| 1160 |
return false; |
| 1161 |
} |
| 1162 |
|
| 1163 |
$order->update_meta_data($key, wp_unslash($value)); |
| 1164 |
|
| 1165 |
// During checkout the order is saved once, after every field. |
| 1166 |
if ($order !== self::$active_order) { |
| 1167 |
$order->save_meta_data(); |
| 1168 |
} |
| 1169 |
|
| 1170 |
return true; |
| 1171 |
} |
| 1172 |
|
| 1173 |
/** |
| 1174 |
* Delete ACF values of an order through WooCommerce. |
| 1175 |
* |
| 1176 |
* @param mixed $result Short-circuit result. |
| 1177 |
* @param mixed $post_id ACF post id. |
| 1178 |
* @param string $name Meta name. |
| 1179 |
* @param bool $hidden True for the "_name" reference. |
| 1180 |
* |
| 1181 |
* @return mixed |
| 1182 |
*/ |
| 1183 |
public static function delete_meta($result, $post_id, $name, $hidden) |
| 1184 |
{ |
| 1185 |
$order_id = self::order_id($post_id); |
| 1186 |
if (null !== $result || !$order_id || self::acf_has_order_storage()) { |
| 1187 |
return $result; |
| 1188 |
} |
| 1189 |
|
| 1190 |
$key = ($hidden ? '_' : '') . $name; |
| 1191 |
$order = self::order($order_id); |
| 1192 |
|
| 1193 |
if (!$order || self::is_reserved_order_key($key)) { |
| 1194 |
return false; |
| 1195 |
} |
| 1196 |
|
| 1197 |
$order->delete_meta_data($key); |
| 1198 |
if ($order !== self::$active_order) { |
| 1199 |
$order->save_meta_data(); |
| 1200 |
} |
| 1201 |
|
| 1202 |
return true; |
| 1203 |
} |
| 1204 |
|
| 1205 |
/* --------------------------------------------------------------------- |
| 1206 |
* Showing stored values |
| 1207 |
* ------------------------------------------------------------------ */ |
| 1208 |
|
| 1209 |
/** |
| 1210 |
* Stored checkout fields of an order as printable rows, grouped by block. |
| 1211 |
* |
| 1212 |
* @param mixed $order Order. |
| 1213 |
* |
| 1214 |
* @return array<int,array{heading:string,rows:array<int,array{key:string,label:string,value:string}>}> |
| 1215 |
*/ |
| 1216 |
private static function stored_rows($order): array |
| 1217 |
{ |
| 1218 |
if (!$order instanceof \WC_Order) { |
| 1219 |
return []; |
| 1220 |
} |
| 1221 |
|
| 1222 |
$record = $order->get_meta(self::ORDER_META, true); |
| 1223 |
if (!is_array($record) || !$record) { |
| 1224 |
return []; |
| 1225 |
} |
| 1226 |
|
| 1227 |
$post_id = 'woo_order_' . $order->get_id(); |
| 1228 |
$groups = []; |
| 1229 |
$previous = self::$active_order; |
| 1230 |
self::$active_order = $order; |
| 1231 |
|
| 1232 |
try { |
| 1233 |
foreach ($record as $block) { |
| 1234 |
$rows = []; |
| 1235 |
foreach ((array) ($block['fields'] ?? []) as $stored) { |
| 1236 |
$key = (string) ($stored['key'] ?? ''); |
| 1237 |
$field = self::acf_ready() && '' !== $key ? acf_get_field($key) : false; |
| 1238 |
|
| 1239 |
if ($field) { |
| 1240 |
$value = self::display_value($field, acf_get_value($post_id, $field)); |
| 1241 |
$label = (string) ($field['label'] ?: $field['name']); |
| 1242 |
} else { |
| 1243 |
// The field was deleted from ACF after the order: show what is stored. |
| 1244 |
$name = (string) ($stored['name'] ?? ''); |
| 1245 |
$value = '' !== $name && !self::is_reserved_order_key($name) ? self::flatten($order->get_meta($name, true)) : ''; |
| 1246 |
$label = (string) ($stored['label'] ?? $name); |
| 1247 |
} |
| 1248 |
|
| 1249 |
// Browsers submit textarea line breaks as \r\n. |
| 1250 |
$value = str_replace(["\r\n", "\r"], "\n", $value); |
| 1251 |
if ('' === $value) { |
| 1252 |
continue; |
| 1253 |
} |
| 1254 |
|
| 1255 |
$rows[] = ['key' => $key, 'label' => $label, 'value' => $value]; |
| 1256 |
} |
| 1257 |
|
| 1258 |
if ($rows) { |
| 1259 |
$groups[] = ['heading' => (string) ($block['heading'] ?? ''), 'rows' => $rows]; |
| 1260 |
} |
| 1261 |
} |
| 1262 |
} finally { |
| 1263 |
self::$active_order = $previous; |
| 1264 |
} |
| 1265 |
|
| 1266 |
return $groups; |
| 1267 |
} |
| 1268 |
|
| 1269 |
/** |
| 1270 |
* Order edit screen: below the customer, in the General column. |
| 1271 |
* |
| 1272 |
* @param mixed $order Order. |
| 1273 |
* |
| 1274 |
* @return void |
| 1275 |
*/ |
| 1276 |
public static function print_admin_values($order): void |
| 1277 |
{ |
| 1278 |
$groups = self::stored_rows($order); |
| 1279 |
if (!$groups) { |
| 1280 |
return; |
| 1281 |
} |
| 1282 |
|
| 1283 |
echo '<div class="ka-order-acf-fields" style="clear:both;padding-top:4px;">'; |
| 1284 |
foreach ($groups as $group) { |
| 1285 |
echo '<h3 style="margin:1em 0 0.5em;">' . esc_html('' !== $group['heading'] ? $group['heading'] : __('Checkout fields', 'king-addons')) . '</h3>'; |
| 1286 |
foreach ($group['rows'] as $row) { |
| 1287 |
echo '<p class="form-field form-field-wide"><strong>' . esc_html($row['label']) . ':</strong><br />' . nl2br(esc_html($row['value'])) . '</p>'; |
| 1288 |
} |
| 1289 |
} |
| 1290 |
echo '</div>'; |
| 1291 |
} |
| 1292 |
|
| 1293 |
/** |
| 1294 |
* Thank-you page and My Account order view. |
| 1295 |
* |
| 1296 |
* @param mixed $order Order. |
| 1297 |
* @param string $context "block" inside WooCommerce's Order Confirmation |
| 1298 |
* blocks, anything else for the classic templates. |
| 1299 |
* |
| 1300 |
* @return void |
| 1301 |
*/ |
| 1302 |
public static function print_customer_values($order, $context = ''): void |
| 1303 |
{ |
| 1304 |
$groups = self::stored_rows($order); |
| 1305 |
if (!$groups) { |
| 1306 |
return; |
| 1307 |
} |
| 1308 |
|
| 1309 |
$block = 'block' === $context; |
| 1310 |
|
| 1311 |
foreach ($groups as $group) { |
| 1312 |
$heading = '' !== $group['heading'] ? $group['heading'] : __('Additional information', 'king-addons'); |
| 1313 |
|
| 1314 |
if ($block) { |
| 1315 |
// Borrow the totals block's table classes so the section looks |
| 1316 |
// like the order table right above it in any block theme. |
| 1317 |
echo '<div class="ka-order-acf-fields ka-order-acf-fields--block alignwide">'; |
| 1318 |
echo '<h2 class="wp-block-heading ka-order-acf-fields__heading">' . esc_html($heading) . '</h2>'; |
| 1319 |
echo '<div class="wc-block-order-confirmation-totals">'; |
| 1320 |
echo '<table cellspacing="0" class="wc-block-order-confirmation-totals__table ka-order-acf-fields__table"><tbody>'; |
| 1321 |
} else { |
| 1322 |
echo '<div class="ka-order-acf-fields">'; |
| 1323 |
echo '<h2 class="woocommerce-column__title">' . esc_html($heading) . '</h2>'; |
| 1324 |
echo '<table class="woocommerce-table shop_table ka-order-acf-fields__table"><tbody>'; |
| 1325 |
} |
| 1326 |
|
| 1327 |
foreach ($group['rows'] as $row) { |
| 1328 |
echo '<tr><th scope="row">' . esc_html($row['label']) . '</th><td>' . nl2br(esc_html($row['value'])) . '</td></tr>'; |
| 1329 |
} |
| 1330 |
|
| 1331 |
echo '</tbody></table>'; |
| 1332 |
echo $block ? '</div></div>' : '</div>'; |
| 1333 |
} |
| 1334 |
} |
| 1335 |
|
| 1336 |
/** |
| 1337 |
* Order Confirmation block template: add the fields after the totals. |
| 1338 |
* |
| 1339 |
* WooCommerce renders the totals wrapper only for a viewer allowed to see |
| 1340 |
* the order's details and returns an empty string otherwise, so the fields |
| 1341 |
* follow the same rule as the rest of the page. |
| 1342 |
* |
| 1343 |
* @param string $content Rendered block. |
| 1344 |
* |
| 1345 |
* @return string |
| 1346 |
*/ |
| 1347 |
public static function append_to_confirmation($content): string |
| 1348 |
{ |
| 1349 |
$content = (string) $content; |
| 1350 |
if ('' === trim($content) || !function_exists('wc_get_order')) { |
| 1351 |
return $content; |
| 1352 |
} |
| 1353 |
|
| 1354 |
$order = wc_get_order(absint(get_query_var('order-received'))); |
| 1355 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the order key is the credential here, as in WooCommerce. |
| 1356 |
$key = isset($_GET['key']) ? wc_clean(wp_unslash($_GET['key'])) : ''; |
| 1357 |
|
| 1358 |
if (!$order instanceof \WC_Order || !$order->key_is_valid($key)) { |
| 1359 |
return $content; |
| 1360 |
} |
| 1361 |
|
| 1362 |
ob_start(); |
| 1363 |
self::print_customer_values($order, 'block'); |
| 1364 |
|
| 1365 |
return $content . (string) ob_get_clean(); |
| 1366 |
} |
| 1367 |
|
| 1368 |
/** |
| 1369 |
* Styles for the order views, which have no Elementor widget to pull them in. |
| 1370 |
* |
| 1371 |
* @return void |
| 1372 |
*/ |
| 1373 |
public static function enqueue_order_view_styles(): void |
| 1374 |
{ |
| 1375 |
if (function_exists('is_wc_endpoint_url') && (is_wc_endpoint_url('order-received') || is_wc_endpoint_url('view-order'))) { |
| 1376 |
wp_enqueue_style(KING_ADDONS_ASSETS_UNIQUE_KEY . '-woo-acf-fields-style'); |
| 1377 |
} |
| 1378 |
} |
| 1379 |
|
| 1380 |
/** |
| 1381 |
* Order emails, to the store and to the customer. |
| 1382 |
* |
| 1383 |
* @param mixed $order Order. |
| 1384 |
* @param bool $sent_to_admin Whether the email goes to the store. |
| 1385 |
* @param bool $plain_text Whether this is the plain-text version. |
| 1386 |
* |
| 1387 |
* @return void |
| 1388 |
*/ |
| 1389 |
public static function print_email_values($order, $sent_to_admin = false, $plain_text = false): void |
| 1390 |
{ |
| 1391 |
foreach (self::stored_rows($order) as $group) { |
| 1392 |
$heading = '' !== $group['heading'] ? $group['heading'] : __('Additional information', 'king-addons'); |
| 1393 |
|
| 1394 |
if ($plain_text) { |
| 1395 |
// Plain text is not HTML: escaping would print " and friends. |
| 1396 |
echo "\n" . (function_exists('wc_strtoupper') ? wc_strtoupper($heading) : strtoupper($heading)) . "\n\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 1397 |
foreach ($group['rows'] as $row) { |
| 1398 |
echo wp_strip_all_tags($row['label']) . ': ' . str_replace("\n", "\n ", wp_strip_all_tags($row['value'])) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 1399 |
} |
| 1400 |
continue; |
| 1401 |
} |
| 1402 |
|
| 1403 |
echo '<h2>' . esc_html($heading) . '</h2>'; |
| 1404 |
foreach ($group['rows'] as $row) { |
| 1405 |
echo '<p><strong>' . esc_html($row['label']) . ':</strong> ' . nl2br(esc_html($row['value'])) . '</p>'; |
| 1406 |
} |
| 1407 |
} |
| 1408 |
} |
| 1409 |
|
| 1410 |
/** |
| 1411 |
* A stored value as text: choice labels instead of choice values, titles |
| 1412 |
* instead of ids, dates in the field's display format. |
| 1413 |
* |
| 1414 |
* @param array<string,mixed> $field ACF field. |
| 1415 |
* @param mixed $value Unformatted value. |
| 1416 |
* |
| 1417 |
* @return string |
| 1418 |
*/ |
| 1419 |
private static function display_value(array $field, $value): string |
| 1420 |
{ |
| 1421 |
$type = (string) ($field['type'] ?? ''); |
| 1422 |
|
| 1423 |
if ('true_false' === $type) { |
| 1424 |
if (null === $value || '' === $value) { |
| 1425 |
return ''; |
| 1426 |
} |
| 1427 |
|
| 1428 |
return !empty($value) ? __('Yes', 'king-addons') : __('No', 'king-addons'); |
| 1429 |
} |
| 1430 |
|
| 1431 |
if (null === $value || '' === $value || [] === $value || false === $value) { |
| 1432 |
return ''; |
| 1433 |
} |
| 1434 |
|
| 1435 |
switch ($type) { |
| 1436 |
case 'select': |
| 1437 |
case 'checkbox': |
| 1438 |
case 'radio': |
| 1439 |
case 'button_group': |
| 1440 |
$choices = (array) ($field['choices'] ?? []); |
| 1441 |
$labels = array_map(static function ($item) use ($choices): string { |
| 1442 |
return (string) ($choices[$item] ?? $item); |
| 1443 |
}, array_filter((array) $value, static function ($item): bool { |
| 1444 |
return is_scalar($item) && '' !== (string) $item; |
| 1445 |
})); |
| 1446 |
return implode(', ', $labels); |
| 1447 |
|
| 1448 |
case 'date_picker': |
| 1449 |
case 'date_time_picker': |
| 1450 |
case 'time_picker': |
| 1451 |
$format = (string) ($field['display_format'] ?? ''); |
| 1452 |
return '' !== $format && function_exists('acf_format_date') ? (string) acf_format_date($value, $format) : (string) $value; |
| 1453 |
|
| 1454 |
case 'link': |
| 1455 |
if (is_array($value)) { |
| 1456 |
$url = (string) ($value['url'] ?? ''); |
| 1457 |
$title = (string) ($value['title'] ?? ''); |
| 1458 |
return '' !== $title && '' !== $url ? $title . ' (' . $url . ')' : ($title . $url); |
| 1459 |
} |
| 1460 |
return (string) $value; |
| 1461 |
|
| 1462 |
case 'post_object': |
| 1463 |
case 'page_link': |
| 1464 |
case 'relationship': |
| 1465 |
return implode(', ', array_map(static function ($item): string { |
| 1466 |
return is_numeric($item) ? get_the_title((int) $item) : (string) $item; |
| 1467 |
}, (array) $value)); |
| 1468 |
|
| 1469 |
case 'taxonomy': |
| 1470 |
return implode(', ', array_filter(array_map(static function ($item): string { |
| 1471 |
$term = is_numeric($item) ? get_term((int) $item) : null; |
| 1472 |
return $term instanceof \WP_Term ? $term->name : ''; |
| 1473 |
}, (array) $value))); |
| 1474 |
|
| 1475 |
case 'user': |
| 1476 |
return implode(', ', array_filter(array_map(static function ($item): string { |
| 1477 |
$user = is_numeric($item) ? get_userdata((int) $item) : false; |
| 1478 |
return $user ? $user->display_name : ''; |
| 1479 |
}, (array) $value))); |
| 1480 |
|
| 1481 |
case 'google_map': |
| 1482 |
return is_array($value) ? (string) ($value['address'] ?? '') : (string) $value; |
| 1483 |
|
| 1484 |
case 'group': |
| 1485 |
$parts = []; |
| 1486 |
foreach ((array) ($field['sub_fields'] ?? []) as $sub_field) { |
| 1487 |
if (!is_array($sub_field) || empty($sub_field['key'])) { |
| 1488 |
continue; |
| 1489 |
} |
| 1490 |
$text = self::display_value($sub_field, is_array($value) ? ($value[$sub_field['key']] ?? null) : null); |
| 1491 |
if ('' !== $text) { |
| 1492 |
$parts[] = ($sub_field['label'] ?: $sub_field['name']) . ': ' . $text; |
| 1493 |
} |
| 1494 |
} |
| 1495 |
return implode("\n", $parts); |
| 1496 |
|
| 1497 |
case 'wysiwyg': |
| 1498 |
return trim(wp_strip_all_tags((string) $value)); |
| 1499 |
} |
| 1500 |
|
| 1501 |
return self::flatten($value); |
| 1502 |
} |
| 1503 |
|
| 1504 |
/** |
| 1505 |
* Any value as a line of text. |
| 1506 |
* |
| 1507 |
* @param mixed $value Value. |
| 1508 |
* |
| 1509 |
* @return string |
| 1510 |
*/ |
| 1511 |
private static function flatten($value): string |
| 1512 |
{ |
| 1513 |
if (is_scalar($value)) { |
| 1514 |
return trim((string) $value); |
| 1515 |
} |
| 1516 |
|
| 1517 |
if (is_array($value)) { |
| 1518 |
$parts = array_filter(array_map([self::class, 'flatten'], $value), static function (string $part): bool { |
| 1519 |
return '' !== $part; |
| 1520 |
}); |
| 1521 |
return implode(', ', $parts); |
| 1522 |
} |
| 1523 |
|
| 1524 |
return ''; |
| 1525 |
} |
| 1526 |
} |
| 1527 |
|