PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.84
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.84
51.1.86 51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 All 40 releases
king-addons / includes / helpers / Woo_Builder / ACF_Fields.php

ACF_Fields.php in King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder 51.1.84, at includes/helpers/Woo_Builder/ACF_Fields.php

1,527 lines 55.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 &quot; 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