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();
}
}