,email:bool} */ final class FormLayout { public const OPTION = 'fxtr_form_layout'; public const REQUIRED = 'required'; public const OPTIONAL = 'optional'; public const HIDDEN = 'hidden'; private const ANSWERS = [ self::REQUIRED, self::OPTIONAL, self::HIDDEN ]; /** Core fields and the input each one is. */ public const CORE = [ 'date' => 'date', 'party_size' => 'number', 'time' => 'select', 'customer_name' => 'text', 'phone' => 'tel', 'email' => 'email', 'notes' => 'textarea', ]; /** Core fields a booking cannot do without. */ public const LOCKED = [ 'date', 'party_size', 'time', 'customer_name' ]; /** Types of the owner's own fields. */ public const TYPES = [ 'text', 'textarea', 'select', 'radio', 'checkbox', 'number', 'date', 'email', 'tel' ]; public const CHOICE_TYPES = [ 'select', 'radio' ]; public const MAX_FIELDS = 30; public const MAX_CHOICES = 30; private const LABEL_MAX = 120; private const HELP_MAX = 300; /** @var list|null */ private static ?array $current = null; /** * The form as it was before the builder: date and guests side by side, * then the time, the name, phone and email side by side, and the notes. * * @return list */ public static function defaults(): array { $half = [ 'date', 'party_size', 'phone', 'email' ]; $out = []; foreach ( self::CORE as $id => $type ) { $out[] = self::entry( [ 'id' => $id, 'type' => $type, 'width' => in_array( $id, $half, true ) ? 'half' : 'full', 'answer' => in_array( $id, self::LOCKED, true ) ? self::REQUIRED : self::OPTIONAL, ] ); } return $out; } /** * The translated label of each core field, for a field whose own label * is empty. * * @return array */ public static function labels(): array { return [ 'date' => __( 'Date', 'flexatech-table-reservations' ), 'party_size' => __( 'Guests', 'flexatech-table-reservations' ), 'time' => __( 'Time', 'flexatech-table-reservations' ), 'customer_name' => __( 'Your name', 'flexatech-table-reservations' ), 'phone' => __( 'Phone', 'flexatech-table-reservations' ), 'email' => __( 'Email', 'flexatech-table-reservations' ), 'notes' => __( 'Notes (allergies, high chair, occasion)', 'flexatech-table-reservations' ), ]; } /** * The label the guest sees. * * @param Entry $entry */ public static function label( array $entry ): string { return '' !== $entry['label'] ? $entry['label'] : ( self::labels()[ $entry['id'] ] ?? $entry['id'] ); } /** The label the guest sees on a field of the current form. */ public static function label_of( string $id ): string { $entry = self::field( $id ); return null === $entry ? ( self::labels()[ $id ] ?? $id ) : self::label( $entry ); } /** * The layout the form and the booking route use, filtered, once per request. * * @return list */ public static function current(): array { if ( null === self::$current ) { $stored = self::stored(); $filtered = apply_filters( 'fxtr_form_fields', $stored ); // A callback may hand back anything: clean it like a stored value. self::$current = is_array( $filtered ) && $filtered !== $stored ? self::from_array( $filtered ) : $stored; } return self::$current; } /** * The saved layout, cleaned, without the filter: what the builder edits. * * @return list */ public static function stored(): array { return self::from_array( get_option( self::OPTION, null ) ); } /** * @return Entry|null */ public static function field( string $id ): ?array { foreach ( self::current() as $entry ) { if ( $entry['id'] === $id ) { return $entry; } } return null; } /** Required, optional or hidden; hidden for a field the form does not have. */ public static function answer( string $id ): string { return self::field( $id )['answer'] ?? self::HIDDEN; } /** * Reads a stored or filtered value, leniently: entries that do not pass * are dropped and missing core fields come back at the end, so a broken * option still gives a form that can book. * * @return list */ public static function from_array( mixed $stored ): array { if ( ! is_array( $stored ) || [] === $stored ) { return self::defaults(); } $out = []; $seen = []; foreach ( array_values( $stored ) as $i => $raw ) { $entry = self::clean( $raw, 'fields.' . $i ); if ( $entry instanceof WP_Error || isset( $seen[ $entry['id'] ] ) || count( $out ) >= self::MAX_FIELDS ) { continue; } $seen[ $entry['id'] ] = true; $out[] = $entry; } foreach ( self::defaults() as $entry ) { if ( ! isset( $seen[ $entry['id'] ] ) ) { $out[] = $entry; } } return self::reachable( $out, false ); } /** * Checks a layout sent by the builder, strictly. The error names the * setting at fault in `data.fields`, as `fields.3.label`. * * @return list|WP_Error */ public static function check( mixed $given ): array|WP_Error { // A list. array_is_list() is PHP 8.1, but Plugin Check ties it to WP 6.5. if ( ! is_array( $given ) || array_values( $given ) !== $given ) { return self::refuse( 'fields', __( 'The form layout is not valid.', 'flexatech-table-reservations' ) ); } if ( count( $given ) > self::MAX_FIELDS ) { /* translators: %d: the most fields a form may have. */ return self::refuse( 'fields', sprintf( __( 'A form may have up to %d fields.', 'flexatech-table-reservations' ), self::MAX_FIELDS ) ); } $out = []; $seen = []; foreach ( $given as $i => $raw ) { $entry = self::clean( $raw, 'fields.' . $i ); if ( $entry instanceof WP_Error ) { return $entry; } if ( isset( $seen[ $entry['id'] ] ) ) { return self::refuse( 'fields.' . $i . '.id', __( 'Two fields have the same id.', 'flexatech-table-reservations' ) ); } $seen[ $entry['id'] ] = true; $out[] = $entry; } foreach ( array_keys( self::CORE ) as $id ) { if ( ! isset( $seen[ $id ] ) ) { return self::refuse( 'fields', __( 'The date, guests, time, name, phone, email and notes fields stay on the form. Hide the ones you do not want.', 'flexatech-table-reservations' ) ); } } return self::reachable( $out, true ); } /** * Saves a layout check() passed. The default layout is not stored, so a * later version can improve it. * * @param list $fields */ public static function save( array $fields ): void { if ( self::defaults() === $fields ) { delete_option( self::OPTION ); } else { update_option( self::OPTION, $fields, false ); } self::$current = null; do_action( 'fxtr_form_layout_saved', $fields ); } public static function reset(): void { delete_option( self::OPTION ); self::$current = null; } /** Forgets the layout read in this request (tests, after a save). */ public static function flush(): void { self::$current = null; } /** * One entry, cleaned, or the reason it cannot be. * * @return Entry|WP_Error */ private static function clean( mixed $raw, string $path ): array|WP_Error { if ( ! is_array( $raw ) ) { return self::refuse( $path, __( 'The form layout is not valid.', 'flexatech-table-reservations' ) ); } $id = (string) ( $raw['id'] ?? '' ); if ( 1 !== preg_match( '/^[a-z][a-z0-9_]{0,39}$/', $id ) ) { return self::refuse( $path . '.id', __( 'A field id is lowercase letters, digits and underscores, starting with a letter.', 'flexatech-table-reservations' ) ); } $core = array_key_exists( $id, self::CORE ); $label = self::text( $raw['label'] ?? '', self::LABEL_MAX ); if ( ! $core ) { if ( empty( $raw['custom'] ) ) { return self::refuse( $path . '.id', __( 'The form layout is not valid.', 'flexatech-table-reservations' ) ); } if ( '' === $label ) { return self::refuse( $path . '.label', __( 'Give the field a label.', 'flexatech-table-reservations' ) ); } } $width = (string) ( $raw['width'] ?? 'full' ); if ( ! in_array( $width, [ 'full', 'half' ], true ) ) { return self::refuse( $path . '.width', __( 'Choose full or half width.', 'flexatech-table-reservations' ) ); } $answer = (string) ( $raw['answer'] ?? self::OPTIONAL ); if ( ! in_array( $answer, self::ANSWERS, true ) ) { return self::refuse( $path . '.answer', __( 'Choose required, optional or hidden.', 'flexatech-table-reservations' ) ); } if ( in_array( $id, self::LOCKED, true ) && self::REQUIRED !== $answer ) { return self::refuse( $path . '.answer', __( 'A booking needs this field, so it is always asked.', 'flexatech-table-reservations' ) ); } $type = $core ? self::CORE[ $id ] : (string) ( $raw['type'] ?? 'text' ); $options = []; if ( ! $core ) { if ( ! in_array( $type, self::TYPES, true ) ) { return self::refuse( $path . '.type', __( 'Unknown field type.', 'flexatech-table-reservations' ) ); } if ( in_array( $type, self::CHOICE_TYPES, true ) ) { $options = self::choices( $raw['options'] ?? null, $path . '.options' ); if ( $options instanceof WP_Error ) { return $options; } } } return self::entry( [ 'id' => $id, 'label' => $label, 'placeholder' => self::text( $raw['placeholder'] ?? '', self::LABEL_MAX ), 'help' => self::text( $raw['help'] ?? '', self::HELP_MAX ), 'width' => $width, 'answer' => $answer, 'custom' => ! $core, 'type' => $type, 'options' => $options, 'email' => ! $core && ! empty( $raw['email'] ), ] ); } /** * @return list|WP_Error */ private static function choices( mixed $given, string $path ): array|WP_Error { if ( ! is_array( $given ) || [] === $given ) { return self::refuse( $path, __( 'Add at least one choice.', 'flexatech-table-reservations' ) ); } if ( count( $given ) > self::MAX_CHOICES ) { /* translators: %d: the most choices a field may have. */ return self::refuse( $path, sprintf( __( 'A field may have up to %d choices.', 'flexatech-table-reservations' ), self::MAX_CHOICES ) ); } $out = []; $values = []; foreach ( array_values( $given ) as $i => $choice ) { $value = is_array( $choice ) ? (string) ( $choice['value'] ?? '' ) : ''; $label = is_array( $choice ) ? self::text( $choice['label'] ?? '', self::LABEL_MAX ) : ''; if ( 1 !== preg_match( '/^[a-z0-9_-]{1,40}$/', $value ) || '' === $label ) { return self::refuse( $path . '.' . $i, __( 'Each choice needs a label.', 'flexatech-table-reservations' ) ); } if ( isset( $values[ $value ] ) ) { return self::refuse( $path . '.' . $i, __( 'Two choices have the same value.', 'flexatech-table-reservations' ) ); } $values[ $value ] = true; $out[] = [ 'value' => $value, 'label' => $label, ]; } return $out; } /** * Keeps a way to reach the guest: with phone or email hidden the other * is required. Both hidden is refused when strict, else email comes back. * * @param list $fields * @return list|WP_Error */ private static function reachable( array $fields, bool $strict ): array|WP_Error { $at = []; foreach ( $fields as $i => $entry ) { $at[ $entry['id'] ] = $i; } $phone = $at['phone'] ?? null; $email = $at['email'] ?? null; if ( null === $phone || null === $email ) { return $fields; } $hidden = [ 'phone' => self::HIDDEN === $fields[ $phone ]['answer'], 'email' => self::HIDDEN === $fields[ $email ]['answer'], ]; if ( $hidden['phone'] && $hidden['email'] ) { if ( $strict ) { return self::refuse( 'fields.' . $email . '.answer', __( 'Keep the phone or the email on the form, so you can reach the guest.', 'flexatech-table-reservations' ) ); } $fields[ $email ]['answer'] = self::REQUIRED; } elseif ( $hidden['phone'] ) { $fields[ $email ]['answer'] = self::REQUIRED; } elseif ( $hidden['email'] ) { $fields[ $phone ]['answer'] = self::REQUIRED; } return $fields; } /** * @param array $given * @return Entry */ private static function entry( array $given ): array { /** @var Entry */ return array_replace( [ 'id' => '', 'label' => '', 'placeholder' => '', 'help' => '', 'width' => 'full', 'answer' => self::OPTIONAL, 'custom' => false, 'type' => 'text', 'options' => [], 'email' => false, ], $given ); } private static function text( mixed $value, int $max ): string { return is_scalar( $value ) ? mb_substr( trim( sanitize_text_field( (string) $value ) ), 0, $max ) : ''; } private static function refuse( string $path, string $message ): WP_Error { return new WP_Error( 'fxtr_invalid_form_layout', $message, [ 'status' => 400, 'fields' => [ $path => $message ], ] ); } }