plugin = $plugin; add_action( 'add_meta_boxes_shapeoptions_group', array( $this, 'metaboxes' ) ); add_action( 'save_post_shapeoptions_group', array( $this, 'save' ), 10, 2 ); add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) ); } /** * Register metaboxes. */ public function metaboxes() { add_meta_box( 'shapeoptions-fields', __( 'Fields', 'shapeoptions' ), array( $this, 'render_fields_box' ), 'shapeoptions_group', 'normal', 'high' ); add_meta_box( 'shapeoptions-conditions', __( 'Show on', 'shapeoptions' ), array( $this, 'render_conditions_box' ), 'shapeoptions_group', 'side' ); add_meta_box( 'shapeoptions-placement', __( 'Placement', 'shapeoptions' ), array( $this, 'render_placement_box' ), 'shapeoptions_group', 'side' ); } /** * Builder assets on the CPT edit screen only. * * @param string $hook Current admin page. */ public function assets( $hook ) { if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) || 'shapeoptions_group' !== get_current_screen()->post_type ) { return; } wp_enqueue_style( 'shapeoptions-admin', SHAPEOPTIONS_URL . 'assets/css/shapeoptions-admin.css', array(), shapeoptions_asset_version( SHAPEOPTIONS_PATH . 'assets/css/shapeoptions-admin.css', SHAPEOPTIONS_VERSION ) ); wp_enqueue_script( 'shapeoptions-admin', SHAPEOPTIONS_URL . 'assets/js/shapeoptions-admin.js', array(), shapeoptions_asset_version( SHAPEOPTIONS_PATH . 'assets/js/shapeoptions-admin.js', SHAPEOPTIONS_VERSION ), true ); wp_localize_script( 'shapeoptions-admin', 'shapeoptionsBuilder', $this->builder_schema() ); wp_enqueue_script( 'wc-enhanced-select' ); wp_enqueue_style( 'woocommerce_admin_styles' ); } /** * Schema that drives the JS builder: field types with their supported * settings, per-choice properties, and available price types. * * @return array */ public function builder_schema() { // Which settings each core type shows. Keys map to builder controls. $supports = array( 'text' => array( 'placeholder', 'maxlength', 'price' ), 'textarea' => array( 'placeholder', 'maxlength', 'price' ), 'number' => array( 'placeholder', 'minmax', 'price' ), 'email' => array( 'placeholder', 'price' ), 'url' => array( 'placeholder', 'price' ), 'phone' => array( 'placeholder', 'price' ), 'date' => array( 'price' ), 'time' => array( 'price' ), 'color' => array( 'price' ), 'range' => array( 'minmax', 'price' ), 'select' => array( 'choices' ), 'radio' => array( 'choices', 'layout' ), 'checkbox' => array( 'price' ), 'checkbox_group' => array( 'choices', 'layout' ), 'switch' => array( 'price' ), 'heading' => array(), 'paragraph' => array( 'content' ), 'separator' => array(), 'section' => array(), 'file' => array( 'file', 'price' ), ); // Types that never store a value (no required/price/description). $static = array( 'heading', 'paragraph', 'separator', 'section' ); // Type groups for the "add field" picker, APF-style. $groups = array( 'basic' => array( __( 'Basic', 'shapeoptions' ), array( 'text', 'textarea', 'number', 'email', 'url', 'phone' ) ), 'selection' => array( __( 'Selection', 'shapeoptions' ), array( 'select', 'radio', 'checkbox', 'checkbox_group', 'switch' ) ), 'datetime' => array( __( 'Date & Time', 'shapeoptions' ), array( 'date', 'time' ) ), 'visual' => array( __( 'Visual', 'shapeoptions' ), array( 'color', 'range' ) ), 'content' => array( __( 'Content', 'shapeoptions' ), array( 'heading', 'paragraph', 'separator', 'section' ) ), 'special' => array( __( 'Special', 'shapeoptions' ), array( 'file' ) ), ); $types = array(); foreach ( $this->plugin->fields->get_labels() as $type => $label ) { $types[ $type ] = array( 'label' => $label, 'supports' => isset( $supports[ $type ] ) ? $supports[ $type ] : array( 'price' ), 'static' => in_array( $type, $static, true ), ); } /** * Available pricing strategies. Add-ons register additional ones; * the builder shows the picker as soon as there is more than one. * * @param array $types type => label. */ $price_types = apply_filters( 'shapeoptions/pricing_types', array( 'flat' => __( 'Flat fee', 'shapeoptions' ) ) ); $schema = array( 'types' => $types, 'groups' => array_values( array_map( function ( $g ) { return array( 'label' => $g[0], 'types' => $g[1] ); }, $groups ) ), 'priceTypes' => $price_types, 'i18n' => array( 'addField' => __( '+ Add field', 'shapeoptions' ), 'addChoice' => __( '+ Add choice', 'shapeoptions' ), 'dragToReorder' => __( 'Drag to reorder', 'shapeoptions' ), 'duplicate' => __( 'Duplicate', 'shapeoptions' ), 'delete' => __( 'Delete', 'shapeoptions' ), 'confirmDel' => __( 'Delete this field?', 'shapeoptions' ), 'noLabel' => __( '(no label)', 'shapeoptions' ), 'type' => __( 'Type', 'shapeoptions' ), 'label' => __( 'Label', 'shapeoptions' ), 'description' => __( 'Instructions', 'shapeoptions' ), 'descHelp' => __( 'Shown as a hint under the field.', 'shapeoptions' ), 'required' => __( 'Required', 'shapeoptions' ), 'price' => __( 'Price', 'shapeoptions' ), 'placeholder' => __( 'Placeholder', 'shapeoptions' ), 'maxlength' => __( 'Max length', 'shapeoptions' ), 'tabGeneral' => __( 'General', 'shapeoptions' ), 'tabStyles' => __( 'Styles', 'shapeoptions' ), 'layout' => __( 'Display style', 'shapeoptions' ), 'layoutInline' => __( 'Inline', 'shapeoptions' ), 'layoutStacked' => __( 'Stacked (one per line)', 'shapeoptions' ), 'layoutBoxed' => __( 'Boxed buttons', 'shapeoptions' ), 'width' => __( 'Field width', 'shapeoptions' ), 'widthAuto' => __( 'Auto', 'shapeoptions' ), 'widthHelp' => __( 'How wide this field renders. Fields on the same row (adjacent, less than 100%) sit side by side.', 'shapeoptions' ), 'shape' => __( 'Corner shape', 'shapeoptions' ), 'shapeGlobal' => __( 'Use global setting', 'shapeoptions' ), 'shapeSharp' => __( 'Sharp', 'shapeoptions' ), 'shapeRounded' => __( 'Rounded', 'shapeoptions' ), 'shapeHelp' => __( 'Overrides the site-wide Style Settings shape for this field only.', 'shapeoptions' ), 'helpPosition' => __( 'Help text position', 'shapeoptions' ), 'helpBelowLabel' => __( 'Below label', 'shapeoptions' ), 'helpTooltip' => __( 'Tooltip', 'shapeoptions' ), 'helpBelowField' => __( 'Below field', 'shapeoptions' ), 'cssClass' => __( 'CSS class', 'shapeoptions' ), 'cssId' => __( 'CSS ID', 'shapeoptions' ), 'min' => __( 'Min', 'shapeoptions' ), 'max' => __( 'Max', 'shapeoptions' ), 'step' => __( 'Step', 'shapeoptions' ), 'content' => __( 'Content (HTML allowed)', 'shapeoptions' ), 'fileTypes' => __( 'Allowed types (comma separated)', 'shapeoptions' ), 'maxSize' => __( 'Max size (MB)', 'shapeoptions' ), 'choices' => __( 'Choices', 'shapeoptions' ), 'choiceLabel' => __( 'Label', 'shapeoptions' ), 'choiceValue' => __( 'Value', 'shapeoptions' ), 'choicePrice' => __( 'Price', 'shapeoptions' ), 'emptyState' => __( 'No fields yet. Click "+ Add field" to build your first option.', 'shapeoptions' ), 'fieldKey' => __( 'Key:', 'shapeoptions' ), 'moreFields' => __( 'More field types', 'shapeoptions' ), ), ); /** * Filter the builder schema. Add-ons registering their own field * types use this to declare which settings those types support. * * @param array $schema Builder schema. */ return apply_filters( 'shapeoptions/builder_schema', $schema ); } /** * Fields builder metabox: the JS app mounts here; state travels in the * hidden JSON input. * * @param WP_Post $post Current post. */ public function render_fields_box( $post ) { wp_nonce_field( 'shapeoptions_save_group', 'shapeoptions_nonce' ); $fields = get_post_meta( $post->ID, '_shapeoptions_fields', true ); $fields = is_array( $fields ) ? array_values( $fields ) : array(); printf( '', esc_attr( wp_json_encode( $fields ) ) ); echo '
'; } /** * Conditions metabox. * * @param WP_Post $post Current post. */ public function render_conditions_box( $post ) { $conditions = get_post_meta( $post->ID, '_shapeoptions_conditions', true ); $type = is_array( $conditions ) && isset( $conditions['type'] ) ? $conditions['type'] : 'all'; $ids = is_array( $conditions ) && isset( $conditions['ids'] ) ? (array) $conditions['ids'] : array(); echo '

'; $saved_ids = array_map( 'absint', $ids ); // WooCommerce AJAX product search — scales to any catalog size. echo '

'; echo '

'; $taxonomies = array( 'categories' => array( 'product_cat', __( 'Categories', 'shapeoptions' ), __( 'Search for categories…', 'shapeoptions' ) ), 'tags' => array( 'product_tag', __( 'Tags', 'shapeoptions' ), __( 'Search for tags…', 'shapeoptions' ) ), ); foreach ( $taxonomies as $cond => $tax ) { list( $taxonomy, $label, $placeholder ) = $tax; printf( '

', esc_attr( $cond ), esc_html( $label ) ); printf( '

'; } /** * Render pickers for condition types registered by add-ons. * Submitted values are folded in via `shapeoptions/sanitize_conditions`. * * @param string $type Currently selected condition type. * @param array $conditions Saved condition config. */ do_action( 'shapeoptions/condition_controls', $type, is_array( $conditions ) ? $conditions : array() ); } /** * Placement metabox. * * @param WP_Post $post Current post. */ public function render_placement_box( $post ) { $placement = get_post_meta( $post->ID, '_shapeoptions_placement', true ) ?: 'before'; ?>

sanitize_fields( json_decode( $raw_json, true, self::MAX_BUILDER_DEPTH ) ); } unset( $raw_json ); update_post_meta( $post_id, '_shapeoptions_fields', $clean ); // Conditions. $cond_type = sanitize_key( wp_unslash( $_POST['shapeoptions_condition_type'] ?? 'all' ) ); $ids = array(); foreach ( array( 'products', 'categories', 'tags' ) as $source ) { if ( $cond_type === $source && isset( $_POST[ 'shapeoptions_condition_' . $source ] ) ) { $ids = array_map( 'absint', (array) wp_unslash( $_POST[ 'shapeoptions_condition_' . $source ] ) ); } } /** * Fold extra submitted condition data into the saved config * registered by add-ons. Handlers must sanitize what they read. * * @param array $conditions Base config: type + ids. * @param int $post_id Option group ID. */ $conditions = apply_filters( 'shapeoptions/sanitize_conditions', array( 'type' => $cond_type, 'ids' => $ids ), $post_id ); update_post_meta( $post_id, '_shapeoptions_conditions', $conditions ); $placement = 'after' === sanitize_key( wp_unslash( $_POST['shapeoptions_placement'] ?? '' ) ) ? 'after' : 'before'; update_post_meta( $post_id, '_shapeoptions_placement', $placement ); /** * Fires after an option group is saved (i18n string registration, * cache busting…). * * @param int $post_id Option group ID. * @param array $fields Clean field configs. */ do_action( 'shapeoptions/group_saved', $post_id, $clean ); } /** * Sanitize a decoded list of builder fields. Anything that is not a list * of field objects yields an empty list; invalid rows are dropped. * * @param mixed $fields Untrusted decoded JSON. * @return array Clean field configs. */ public function sanitize_fields( $fields ) { $clean = array(); $seen = array(); foreach ( is_array( $fields ) ? $fields : array() as $raw ) { if ( ! is_array( $raw ) ) { continue; } $field = $this->sanitize_field( $raw, $seen ); if ( $field ) { $seen[] = $field['key']; $clean[] = $field; } } return $clean; } /** * Sanitize one field from the builder JSON. Public so the import tool * runs untrusted import files through the exact same rules. * * @param array $raw Untrusted field object. * @param string[] $seen Keys already used in this group. * @return array|null Clean config, or null to drop the row. */ public function sanitize_field( $raw, $seen ) { $type = sanitize_key( $raw['type'] ?? 'text' ); $label = sanitize_text_field( $raw['label'] ?? '' ); if ( ! $this->plugin->fields->get( $type ) ) { return null; } if ( '' === $label && 'separator' !== $type ) { return null; } $key = sanitize_key( $raw['key'] ?? '' ); if ( '' === $key || in_array( $key, $seen, true ) ) { $key = substr( sanitize_key( $label . '_' . wp_generate_password( 6, false, false ) ), 0, 32 ); } $layout = (string) ( $raw['layout'] ?? '' ); $shape = (string) ( $raw['shape'] ?? '' ); $width = (string) ( $raw['width'] ?? '' ); $help_pos = (string) ( $raw['help_position'] ?? '' ); $field = array( 'type' => $type, 'label' => $label, 'key' => $key, 'description' => sanitize_text_field( $raw['description'] ?? '' ), 'required' => ! empty( $raw['required'] ) ? 1 : 0, 'price' => isset( $raw['price'] ) && '' !== $raw['price'] ? (float) $raw['price'] : 0, 'placeholder' => sanitize_text_field( $raw['placeholder'] ?? '' ), 'min' => sanitize_text_field( $raw['min'] ?? '' ), 'max' => sanitize_text_field( $raw['max'] ?? '' ), 'step' => sanitize_text_field( $raw['step'] ?? '' ), 'maxlength' => ! empty( $raw['maxlength'] ) ? absint( $raw['maxlength'] ) : '', 'content' => wp_kses_post( $raw['content'] ?? '' ), 'file_types' => sanitize_text_field( $raw['file_types'] ?? '' ), 'max_size' => isset( $raw['max_size'] ) && '' !== $raw['max_size'] ? (float) $raw['max_size'] : '', 'choices' => $this->sanitize_choices( $raw['choices'] ?? array() ), 'layout' => in_array( $layout, array( 'inline', 'stacked', 'boxed' ), true ) ? $layout : 'inline', 'shape' => in_array( $shape, array( '', 'sharp', 'rounded' ), true ) ? $shape : '', 'width' => in_array( $width, array( '', '25', '33', '50', '66', '100' ), true ) ? $width : '', 'help_position' => in_array( $help_pos, array( 'below_label', 'tooltip', 'below_field' ), true ) ? $help_pos : 'below_label', 'css_class' => implode( ' ', array_map( 'sanitize_html_class', preg_split( '/\s+/', trim( (string) ( $raw['css_class'] ?? '' ) ), -1, PREG_SPLIT_NO_EMPTY ) ) ), 'css_id' => preg_match( '/^[A-Za-z][\w-]*$/', (string) ( $raw['css_id'] ?? '' ) ) ? sanitize_html_class( $raw['css_id'] ) : '', ); /** * Filter a field config before save. Add-ons registering extra * settings sanitize and attach them here. * * @param array $field Clean config. * @param array $raw Raw builder object. */ return apply_filters( 'shapeoptions/sanitize_field', $field, $raw ); } /** * Sanitize structured choice rows. Add-ons that add extra columns to a * choice row attach their own sanitized values via the * `shapeoptions/sanitize_choice` filter below. * * @param mixed $choices Raw choices array. * @return array[] */ private function sanitize_choices( $choices ) { $clean = array(); foreach ( is_array( $choices ) ? $choices : array() as $choice ) { if ( ! is_array( $choice ) ) { continue; } $label = sanitize_text_field( $choice['label'] ?? '' ); if ( '' === $label ) { continue; } $row = array( 'label' => $label, 'value' => sanitize_title( '' !== (string) ( $choice['value'] ?? '' ) ? $choice['value'] : $label ), 'price' => isset( $choice['price'] ) && '' !== $choice['price'] ? (float) $choice['price'] : 0, ); /** * Filter one clean choice row before save. Add-ons that render * extra choice columns sanitize and attach their values here. * * @param array $row Clean choice row. * @param array $choice Raw choice row from the builder. */ $clean[] = apply_filters( 'shapeoptions/sanitize_choice', $row, $choice ); } return $clean; } } endif;