['href' => [], 'target' => [], 'rel' => []]]; } /** * Every field type. * * input — which element it renders as * value — whether it collects a value (a divider does not) * options — whether it needs a list of options */ public static function types(): array { return [ 'text' => ['label' => __('Single-line text', 'aumframe'), 'input' => 'text'], 'textarea' => ['label' => __('Multi-line text', 'aumframe'), 'input' => 'textarea'], 'email' => ['label' => __('Email', 'aumframe'), 'input' => 'email'], 'tel' => ['label' => __('Phone', 'aumframe'), 'input' => 'tel'], 'number' => ['label' => __('Number', 'aumframe'), 'input' => 'number'], 'select' => ['label' => __('Dropdown', 'aumframe'), 'input' => 'select', 'options' => true], 'radio' => ['label' => __('Radio buttons', 'aumframe'), 'input' => 'radio', 'options' => true], 'checkbox' => ['label' => __('Checkboxes', 'aumframe'), 'input' => 'checkbox', 'options' => true], 'consent' => ['label' => __('Consent', 'aumframe'), 'input' => 'consent'], 'hidden' => ['label' => __('Hidden field', 'aumframe'), 'input' => 'hidden'], ]; } /** * Where dynamic options come from. * * A field like "which model are you interested in" should offer **the models that actually exist on the * site** rather than a hand-copied list — copy it once and the client adds a model, the form is missing one, * and nothing will remind them. Through this channel, adding a bike adds an option. */ public static function resolve_options(array $field): array { $src = $field['source'] ?? null; if (empty($src['kind']) || empty($src['name'])) { // Static options: one per line $raw = (string) ($field['options'] ?? ''); $out = []; foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) { $line = trim($line); if ($line !== '') { $out[] = ['value' => $line, 'label' => $line]; } } return $out; } $out = []; if ($src['kind'] === 'post_type' && post_type_exists($src['name'])) { $posts = get_posts([ 'post_type' => $src['name'], 'numberposts' => 200, 'orderby' => 'menu_order title', 'order' => 'ASC', 'post_status' => 'publish', ]); foreach ($posts as $p) { // The value stores an ID and the label shows the title — renaming a title does not change // which bike an enquiry already received refers to $out[] = ['value' => (string) $p->ID, 'label' => get_the_title($p)]; } } elseif ($src['kind'] === 'taxonomy' && taxonomy_exists($src['name'])) { $terms = get_terms(['taxonomy' => $src['name'], 'hide_empty' => false, 'number' => 200]); if (!is_wp_error($terms)) { foreach ($terms as $t) { $out[] = ['value' => (string) $t->term_id, 'label' => $t->name]; } } } return $out; } /** * Reduce a field's raw submitted value to a clean one. * * @return array{ok:bool,value:mixed,error:string} */ public static function accept(array $field, $raw): array { $type = $field['type'] ?? 'text'; $required = !empty($field['required']); $label = $field['label'] ?? ($field['id'] ?? ''); if ($type === 'checkbox') { $vals = is_array($raw) ? array_map('sanitize_text_field', $raw) : []; $valid = wp_list_pluck(self::resolve_options($field), 'value'); $vals = array_values(array_intersect($vals, $valid)); if ($required && !$vals) { /* translators: %s: The label of the field the visitor left unanswered. */ return ['ok' => false, 'value' => [], 'error' => sprintf(__('Please choose “%s”', 'aumframe'), $label)]; } return ['ok' => true, 'value' => $vals, 'error' => '']; } if ($type === 'consent') { $on = !empty($raw); if ($required && !$on) { /* translators: %s: The label of the checkbox the visitor has to tick. */ return ['ok' => false, 'value' => '', 'error' => sprintf(__('Please tick “%s”', 'aumframe'), $label)]; } return ['ok' => true, 'value' => $on ? '1' : '', 'error' => '']; } $value = is_array($raw) ? '' : trim((string) $raw); if ($value === '') { if ($required) { /* translators: %s: The label of the field the visitor left empty. */ return ['ok' => false, 'value' => '', 'error' => sprintf(__('Please fill in “%s”', 'aumframe'), $label)]; } return ['ok' => true, 'value' => '', 'error' => '']; } switch ($type) { case 'email': if (!is_email($value)) { /* translators: %s: The label of the email field. */ return ['ok' => false, 'value' => '', 'error' => sprintf(__('“%s” is not a valid email address', 'aumframe'), $label)]; } return ['ok' => true, 'value' => sanitize_email($value), 'error' => '']; case 'tel': // Keep digits and common separators only. No country-format validation — conventions vary // enormously between countries, and strict validation only turns away real customers while // stopping no bots (that is anti-spam's job). $clean = preg_replace('/[^0-9+\-\s()]/', '', $value); if (strlen(preg_replace('/\D/', '', $clean)) < 6) { /* translators: %s: The label of the number field. */ return ['ok' => false, 'value' => '', 'error' => sprintf(__('“%s” doesn’t look like a valid number', 'aumframe'), $label)]; } return ['ok' => true, 'value' => sanitize_text_field($clean), 'error' => '']; case 'number': if (!is_numeric($value)) { /* translators: %s: The label of the number field. */ return ['ok' => false, 'value' => '', 'error' => sprintf(__('“%s” must be a number', 'aumframe'), $label)]; } return ['ok' => true, 'value' => sanitize_text_field($value), 'error' => '']; case 'select': case 'radio': $valid = wp_list_pluck(self::resolve_options($field), 'value'); if (!in_array($value, $valid, true)) { /* translators: %s: The label of the field whose chosen option is not one of the offered ones. */ return ['ok' => false, 'value' => '', 'error' => sprintf(__('“%s” has an invalid option', 'aumframe'), $label)]; } return ['ok' => true, 'value' => sanitize_text_field($value), 'error' => '']; case 'textarea': return ['ok' => true, 'value' => sanitize_textarea_field($value), 'error' => '']; default: return ['ok' => true, 'value' => sanitize_text_field($value), 'error' => '']; } } /** * A value -> readable display text (for the admin list and the mail body). * * Dynamic options store an ID, and displaying "37" means nothing — it has to be turned back into a title. */ /** * A field that collects a value nobody needs to read: a hidden field with no label (a tracking id, a * campaign tag). Those stay out of the mail body and the entries table. * * A hidden field with a label is the other kind (2026-09-15, template #12): a value the page fills in for * the visitor — the enquiry list's item summary — that the recipient must see. Until then every hidden field * was silent, so the one thing the enquiry was about reached the inbox as nothing at all. */ public static function is_silent(array $field): bool { return ($field['type'] ?? '') === 'hidden' && trim((string) ($field['label'] ?? '')) === ''; } public static function display_value(array $field, $value): string { $type = $field['type'] ?? 'text'; if ($type === 'consent') { return $value ? __('Agreed', 'aumframe') : __('Not agreed', 'aumframe'); } if (in_array($type, ['select', 'radio', 'checkbox'], true)) { $map = []; foreach (self::resolve_options($field) as $o) { $map[$o['value']] = $o['label']; } $vals = is_array($value) ? $value : [$value]; $out = []; foreach ($vals as $v) { // Show the raw value even after an option is deleted — a past enquiry must not go blank $out[] = $map[$v] ?? $v; } return implode('、', array_filter($out, 'strlen')); } return is_array($value) ? implode('、', $value) : (string) $value; } }