setup_hooks(); } /** * Set up hooks for various purposes. * * This method adds hooks for different purposes as needed. * * @since 1.0.0 * * @return void */ protected function setup_hooks(): void { $render_block_hook = sprintf( 'render_block_%s', self::BLOCK_NAME ); add_filter( $render_block_hook, array( $this, 'transform_block_content' ), 5, 2 ); add_filter( 'render_block', array( $this, 'apply_visibility_attribute' ), 10, 2 ); add_action( 'save_post', array( $this, 'save_form_schema' ) ); // Add hooks for conditional form field processing. $general_block = General_Block::get_instance(); add_filter( $render_block_hook, array( $general_block, 'process_guests_field' ), 10, 2 ); add_filter( $render_block_hook, array( $general_block, 'process_anonymous_field' ), 10, 2 ); add_filter( $render_block_hook, array( $this, 'process_form_field_attributes' ), 10, 2 ); } /** * Transform block content to create a functional RSVP form. * * Converts the block's div container to a form element and adds necessary * hidden inputs for RSVP processing. Sets the form action to wp-comments-post.php * and method to POST to enable form submission handling through WordPress's * comment system. Generates a unique form ID for redirect handling. * * @since 1.0.0 * * @param string $block_content The original block content. * @param array $block The block instance array, used to determine the event. * * @return string The modified block content as a functional RSVP form. */ public function transform_block_content( string $block_content, array $block ): string { $block_instance = Block::get_instance(); $post_id = $block_instance->get_post_id( $block ); // Validate that the post ID is an actual event post type. // Only check publish status if not in preview mode. if ( Event::POST_TYPE !== get_post_type( $post_id ) || ( ! is_preview() && 'publish' !== get_post_status( $post_id ) ) ) { return ''; } $unique_form_id = $this->generate_form_id(); $schema_form_id = $this->get_form_schema_id( $post_id, $block ); $block_content = trim( $block_content ); $block_content = preg_replace( '/^)$/', '' . '' . '' . '' . '', $block_content ); $tag = new WP_HTML_Tag_Processor( $block_content ); $tag->next_tag(); $tag->set_attribute( 'action', site_url( 'wp-comments-post.php' ) ); $tag->set_attribute( 'method', 'post' ); $tag->set_attribute( 'id', $unique_form_id ); // Add interactivity attributes for Ajax form handling. $tag->set_attribute( 'data-wp-interactive', 'gatherpress' ); $tag->set_attribute( 'data-wp-init', 'callbacks.initRsvpForm' ); $tag->set_attribute( 'data-wp-on--submit', 'actions.handleRsvpFormSubmit' ); $tag->set_attribute( 'data-wp-context', wp_json_encode( array( 'postId' => $post_id ) ) ); $event = new Event( $post_id ); // Add event state if the event has passed. if ( $event->has_event_past() ) { $tag->set_attribute( 'data-gatherpress-event-state', 'past' ); } $updated_html = $tag->get_updated_html(); // Check if this is a successful form submission redirect. $success_param = Utility::get_http_input( INPUT_GET, 'gatherpress_rsvp_success' ); $is_success = 'true' === $success_param; // Handle visibility of form elements based on success state and data attributes. $updated_html = $this->handle_form_visibility( $updated_html, $is_success ); return $updated_html; } /** * Apply visibility data attribute to blocks based on metadata. * * This filter runs on every block as it's being rendered. If a block has * metadata.gatherpressRsvpFormVisibility set, adds the corresponding * data attribute(s) to the rendered HTML and potentially hides the block * based on event state. * * @since 1.0.0 * * @param string $block_content The rendered block content. * @param array $block The block instance array. * @return string The modified block content or empty string if block should be hidden. */ public function apply_visibility_attribute( string $block_content, array $block ): string { // Check if this block has a visibility setting in metadata. $visibility = $block['attrs']['metadata']['gatherpressRsvpFormVisibility'] ?? null; if ( ! $visibility || empty( $block_content ) ) { return $block_content; } // Check if event has passed. $is_past = false; $post_id = $this->get_post_id_from_context( $block ); if ( $post_id ) { $event = new Event( $post_id ); $is_past = $event->has_event_past(); } // Check if this is a success redirect (form was just submitted). $is_success = 'true' === Utility::get_http_input( INPUT_GET, 'gatherpress_rsvp_success' ); // Determine if block should be visible using centralized logic. $should_show = $this->determine_visibility( wp_json_encode( $visibility ), $is_success, $is_past ); // Add the visibility data attribute(s) to the rendered block. $tag = new WP_HTML_Tag_Processor( $block_content ); if ( $tag->next_tag() ) { // Store visibility as JSON. $tag->set_attribute( 'data-gatherpress-rsvp-form-visibility', wp_json_encode( $visibility ) ); // Add event state for frontend JavaScript. if ( $is_past ) { $tag->set_attribute( 'data-gatherpress-event-state', 'past' ); } // Apply server-side visibility to prevent FOUC (Flash of Unstyled Content). if ( false === $should_show ) { // Hide the element with display: none and aria-hidden. $existing_styles = $tag->get_attribute( 'style' ) ?? ''; $updated_styles = trim( $existing_styles . ' display: none;' ); $tag->set_attribute( 'style', $updated_styles ); $tag->set_attribute( 'aria-hidden', 'true' ); } elseif ( true === $should_show ) { // Show the element explicitly. $tag->set_attribute( 'aria-hidden', 'false' ); // Add accessibility attributes for success messages. if ( $is_success ) { $tag->set_attribute( 'aria-live', 'polite' ); $tag->set_attribute( 'role', 'status' ); } } return $tag->get_updated_html(); } return $block_content; } /** * Get the post ID from the block context. * * Attempts to find the event post ID by looking for a parent RSVP form block * or from the current global post. * * @since 1.0.0 * * @param array $block The block instance array. * @return int|null The post ID or null if not found. */ private function get_post_id_from_context( array $block ): ?int { // Try to get from postId attribute (post ID override support). if ( isset( $block['attrs']['postId'] ) ) { return intval( $block['attrs']['postId'] ); } // Fall back to current post. $post_id = get_the_ID(); return $post_id ? $post_id : null; } /** * Handle visibility of form elements based on success state and block attributes. * * Uses the gatherpressRsvpFormVisibility attribute to determine which blocks should * be shown or hidden based on form success state. This provides flexible control * over any inner blocks within the RSVP form. * * @since 1.0.0 * * @param string $html The form HTML content. * @param bool $is_success Whether the form was successfully submitted. * @return string The modified HTML with appropriate visibility. */ private function handle_form_visibility( string $html, bool $is_success ): string { $tag = new WP_HTML_Tag_Processor( $html ); // Check if event has passed by looking for the data attribute on the form element. $is_past = false; if ( $tag->next_tag( array( 'tag_name' => 'form' ) ) ) { $event_state = $tag->get_attribute( 'data-gatherpress-event-state' ); $is_past = 'past' === $event_state; } // Reset to beginning and loop through all elements. $tag = new WP_HTML_Tag_Processor( $html ); while ( $tag->next_tag() ) { $visibility_attr = $tag->get_attribute( 'data-gatherpress-rsvp-form-visibility' ); if ( $visibility_attr ) { $this->apply_visibility_rule( $tag, $visibility_attr, $is_success, $is_past ); } } return $tag->get_updated_html(); } /** * Apply visibility rule to a specific HTML element. * * @since 1.0.0 * * @param WP_HTML_Tag_Processor $tag The HTML tag processor. * @param string $visibility_rule The visibility rule as a JSON object. * @param bool $is_success Whether the form was successfully submitted. * @param bool $is_past Whether the event has passed. * @return void */ private function apply_visibility_rule( WP_HTML_Tag_Processor $tag, ?string $visibility_rule, bool $is_success, bool $is_past ): void { if ( ! $visibility_rule ) { return; } $should_show = $this->determine_visibility( $visibility_rule, $is_success, $is_past ); // Apply visibility if determined. if ( false === $should_show ) { // Hide the element with display: none. $existing_styles = $tag->get_attribute( 'style' ) ?? ''; $updated_styles = trim( $existing_styles . ' display: none;' ); $tag->set_attribute( 'style', $updated_styles ); $tag->set_attribute( 'aria-hidden', 'true' ); } elseif ( true === $should_show ) { // Show the element (remove any inline display: none). $tag->set_attribute( 'aria-hidden', 'false' ); // Add accessibility attributes for success messages. if ( $is_success ) { $tag->set_attribute( 'aria-live', 'polite' ); $tag->set_attribute( 'role', 'status' ); } } } /** * Determine if a block should be visible based on visibility rules and current state. * * @since 1.0.0 * * @param string $visibility_rule The visibility rule as a JSON object. * @param bool $is_success Whether the form was successfully submitted. * @param bool $is_past Whether the event has passed. * @return bool|null True to show, false to hide, null for no change (always visible). */ private function determine_visibility( string $visibility_rule, bool $is_success, bool $is_past ): ?bool { // Decode JSON object format. $visibility = json_decode( $visibility_rule, true ); if ( ! is_array( $visibility ) ) { return null; } // Object format with onSuccess and whenPast. $on_success = $visibility['onSuccess'] ?? ''; $when_past = $visibility['whenPast'] ?? ''; // When event is past, check whenPast (takes precedence when past). if ( $is_past && ! empty( $when_past ) ) { return 'show' === $when_past; } // When not past but block has ONLY whenPast setting (no onSuccess). // This handles blocks that should only appear after event has passed. if ( ! $is_past && ! empty( $when_past ) && empty( $on_success ) ) { return 'show' !== $when_past; // Hide if set to show (because not past yet). } // Check onSuccess. if ( ! empty( $on_success ) ) { if ( $is_success ) { return 'show' === $on_success; } // Not success: hide if set to show on success. return 'show' !== $on_success; } return null; // Default: no change (always visible). } /** * Save the form schema when a post is saved. * * Extracts the form field configuration from RSVP Form blocks and stores * it as post meta. This schema is later used to validate form submissions * and prevent unauthorized field injection. * * @since 1.0.0 * * @param int $post_id The post ID being saved. * @return void */ public function save_form_schema( int $post_id ): void { // Check if this is an autosave or revision. if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) { return; } // Check if user has permission to edit the post. if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } // Get the post content. $post = get_post( $post_id ); if ( ! $post || empty( $post->post_content ) ) { return; } // Parse blocks and extract schemas for each RSVP Form. $blocks = parse_blocks( $post->post_content ); $schemas = $this->extract_form_schemas_from_blocks( $blocks ); if ( ! empty( $schemas ) ) { // Save schemas as post meta. update_post_meta( $post_id, 'gatherpress_rsvp_form_schemas', $schemas ); } else { // Remove schema meta if no RSVP forms found. delete_post_meta( $post_id, 'gatherpress_rsvp_form_schemas' ); } } /** * Extract form schemas from parsed blocks. * * Searches through blocks to find RSVP Form blocks and creates * a separate schema for each form based on its position. * * @since 1.0.0 * * @param array $blocks Array of parsed blocks. * @return array Array of form schemas keyed by form ID. */ private function extract_form_schemas_from_blocks( array $blocks ): array { $schemas = array(); foreach ( $blocks as $index => $block ) { if ( self::BLOCK_NAME === $block['blockName'] ) { $form_id = 'form_' . $index; $fields = $this->extract_form_fields_from_inner_blocks( $block['innerBlocks'] ?? array() ); if ( ! empty( $fields ) ) { $schemas[ $form_id ] = array( 'fields' => $fields, 'hash' => wp_hash( wp_json_encode( $fields ) ), ); } } // Recursively check inner blocks for nested RSVP forms. if ( ! empty( $block['innerBlocks'] ) ) { $nested_schemas = $this->extract_form_schemas_from_blocks( $block['innerBlocks'] ); foreach ( $nested_schemas as $nested_form_id => $nested_schema ) { // Prefix nested forms with parent block index to maintain uniqueness. $prefixed_form_id = $index . '_' . $nested_form_id; $schemas[ $prefixed_form_id ] = $nested_schema; } } } return $schemas; } /** * Extract form fields from inner blocks of RSVP Form. * * Processes the inner blocks of an RSVP Form block to identify * form field blocks and extract their configuration. * * @since 1.0.0 * * @param array $inner_blocks Array of inner blocks. * @return array Array of form field configurations. */ private function extract_form_fields_from_inner_blocks( array $inner_blocks ): array { $fields = array(); foreach ( $inner_blocks as $inner_block ) { // Check for GatherPress form field blocks. if ( Form_Field::BLOCK_NAME === $inner_block['blockName'] ) { $attrs = $inner_block['attrs'] ?? array(); if ( ! empty( $attrs['fieldName'] ) ) { $field_config = array( 'name' => sanitize_key( $attrs['fieldName'] ), 'type' => sanitize_text_field( $attrs['fieldType'] ?? 'text' ), 'required' => (bool) ( $attrs['required'] ?? false ), 'label' => sanitize_text_field( $attrs['label'] ?? '' ), 'placeholder' => sanitize_text_field( $attrs['placeholder'] ?? '' ), ); // Add type-specific validation rules. switch ( $field_config['type'] ) { case 'email': $field_config['validation'] = 'email'; break; case 'select': case 'radio': $field_config['options'] = array_map( 'sanitize_text_field', $attrs['options'] ?? array() ); break; case 'textarea': $field_config['max_length'] = intval( $attrs['maxLength'] ?? 1000 ); break; default: // Nothing to see here. Other field types don't need special handling. } $fields[ $field_config['name'] ] = $field_config; } } // Recursively check inner blocks (e.g., group blocks containing form fields). if ( ! empty( $inner_block['innerBlocks'] ) ) { $nested_fields = $this->extract_form_fields_from_inner_blocks( $inner_block['innerBlocks'] ); $fields = array_merge( $fields, $nested_fields ); } } return $fields; } /** * Get the form schema ID for a specific RSVP Form block. * * Determines the position-based schema ID for this form block * by parsing the current post content and finding its index. * * @since 1.0.0 * * @param int $post_id The post ID. * @param array $block The current block being rendered. * @return string The form schema ID (e.g., 'form_0', 'form_2'). */ private function get_form_schema_id( int $post_id, array $block ): string { $post = get_post( $post_id ); if ( ! $post || empty( $post->post_content ) ) { return 'form_0'; // Fallback. } $blocks = parse_blocks( $post->post_content ); $form_index = $this->find_form_index_in_blocks( $blocks, $block ); return 'form_' . $form_index; } /** * Find the index of the current form block in the blocks array. * * Recursively searches through blocks to find the current RSVP Form * block and returns its position index. * * @since 1.0.0 * * @param array $blocks Array of parsed blocks. * @param array $target_block The block we're looking for. * @param int $base_index Base index for nested blocks. * @return int The index of the form block. */ private function find_form_index_in_blocks( array $blocks, array $target_block, int $base_index = 0 ): int { foreach ( $blocks as $index => $block ) { if ( self::BLOCK_NAME === $block['blockName'] && $this->blocks_match( $block, $target_block ) ) { // Compare block content or attributes to identify the same block. return $base_index + $index; } // Check nested blocks. if ( ! empty( $block['innerBlocks'] ) ) { $nested_index = $this->find_form_index_in_blocks( $block['innerBlocks'], $target_block, $base_index + $index * 100 // Use larger offset for nested blocks. ); if ( -1 !== $nested_index ) { return $nested_index; } } } return 0; // Fallback to first form. } /** * Check if two blocks match based on their content. * * Compares blocks to determine if they are the same instance. * Uses inner HTML content as the primary comparison method. * * @since 1.0.0 * * @param array $block1 First block to compare. * @param array $block2 Second block to compare. * @return bool True if blocks match. */ private function blocks_match( array $block1, array $block2 ): bool { // Compare inner HTML content as a way to identify the same block. $content1 = $block1['innerHTML'] ?? ''; $content2 = $block2['innerHTML'] ?? ''; return $content1 === $content2; } /** * Generate a unique form ID for RSVP redirect handling. * * Creates a unique identifier that can be used to track form submissions * and handle redirects back to the correct page location. * * @since 1.0.0 * * @return string Unique form ID. */ private function generate_form_id(): string { return uniqid( 'gatherpress_rsvp_' ); } /** * Process custom fields for form submissions. * * Validates and saves custom fields from form submissions * using the same schema validation as the Ajax form handler. * * @since 1.0.0 * * @param int $comment_id The comment ID of the RSVP. * @return void */ public function process_custom_fields_for_form( int $comment_id ): void { $comment = get_comment( $comment_id ); if ( ! $comment || Rsvp::COMMENT_TYPE !== $comment->comment_type ) { return; } $post_id = (int) $comment->comment_post_ID; $form_schema_id = Utility::get_http_input( INPUT_POST, 'gatherpress_form_schema_id' ); if ( empty( $form_schema_id ) ) { return; } // Get the stored schemas for this post. $schemas = get_post_meta( $post_id, 'gatherpress_rsvp_form_schemas', true ); if ( empty( $schemas ) || ! isset( $schemas[ $form_schema_id ] ) ) { return; } $schema = $schemas[ $form_schema_id ]; if ( empty( $schema['fields'] ) ) { return; } // Process each custom field from the schema. foreach ( $schema['fields'] as $field_name => $field_config ) { // Skip built-in fields. if ( in_array( $field_name, self::BUILT_IN_FIELDS, true ) ) { continue; } $field_value = Utility::get_http_input( INPUT_POST, $field_name, null ); if ( empty( $field_value ) ) { continue; } // Validate and sanitize the field value using the same logic as the Ajax handler. $validated_value = $this->sanitize_custom_field_value( $field_value, $field_config ); if ( false !== $validated_value ) { update_comment_meta( $comment_id, 'gatherpress_custom_' . $field_name, $validated_value ); } } } /** * Sanitize a custom field value against its configuration. * * Shared sanitization logic for both traditional and Ajax form submissions. * * @since 1.0.0 * * @param mixed $value The field value to sanitize. * @param array $config The field configuration from the schema. * @return mixed|false The sanitized value, or false if sanitization fails. */ public function sanitize_custom_field_value( $value, array $config ) { // Handle required field validation. if ( ! empty( $config['required'] ) && empty( $value ) ) { return false; } // Handle type-specific validation. switch ( $config['type'] ) { case 'email': $sanitized = sanitize_email( $value ); return is_email( $sanitized ) ? $sanitized : false; case 'url': $sanitized = esc_url_raw( $value ); return filter_var( $sanitized, FILTER_VALIDATE_URL ) ? $sanitized : false; case 'number': return is_numeric( $value ) ? floatval( $value ) : false; case 'select': case 'radio': $sanitized = sanitize_text_field( $value ); return in_array( $sanitized, $config['options'] ?? array(), true ) ? $sanitized : false; case 'checkbox': return ! empty( $value ) ? 1 : 0; case 'textarea': $sanitized = sanitize_textarea_field( $value ); $max_length = $config['max_length'] ?? 1000; return strlen( $sanitized ) <= $max_length ? $sanitized : false; case 'text': default: return sanitize_text_field( $value ); } } /** * Process form field attributes for RSVP Form fields. * * Sets the max attribute for guest count fields based on event settings. * * @since 1.0.0 * * @param string $block_content The block content. * @param array $block The block data. * @return string The processed block content. */ public function process_form_field_attributes( string $block_content, array $block ): string { // Get the correct post ID using override logic. $block_instance = Block::get_instance(); $post_id = $block_instance->get_post_id( $block ); // Get max guest limit from event settings. $max_guest_limit = get_post_meta( $post_id, 'gatherpress_max_guest_limit', true ); // Only process if max guest limit is numeric. if ( ! is_numeric( $max_guest_limit ) ) { return $block_content; } // Set max attribute on guest count input fields. $tag = new WP_HTML_Tag_Processor( $block_content ); while ( $tag->next_tag( array( 'tag_name' => 'input' ) ) ) { $name_attr = $tag->get_attribute( 'name' ); if ( 'gatherpress_rsvp_form_guests' === $name_attr ) { $tag->set_attribute( 'max', (string) $max_guest_limit ); } } return $tag->get_updated_html(); } }