true ) ); $presets = array(); foreach ( $rules['weekdayPresets'] ?? array() as $preset ) { $presets[ $preset['key'] ] = $preset['values']; } return $presets; } /** * Query arguments of the public results route while it renders (null: read the page address). * * @var array|null */ private static $request_args = null; /** Freshness and recovery metadata for the current fragment. * * @var array */ private static $response_meta = array(); /** * Render a saved configuration from the shortcode. * * Accepts id="" or config="", plus optional limit (1–12), layout * (cards|list), heading_level (2–6) and style="compact". Invalid values are ignored. * * @param mixed $attributes Attributes. * * @return string Rendered output. */ public static function shortcode( $attributes ) { $attributes = shortcode_atts( array( 'id' => '', 'config' => '', 'limit' => '', 'layout' => '', 'heading_level' => '', 'style' => '', 'instance' => '', ), $attributes, 'actikidz_activities' ); $profile_id = is_scalar( $attributes['id'] ) ? trim( (string) $attributes['id'] ) : ''; $config = is_scalar( $attributes['config'] ) ? trim( (string) $attributes['config'] ) : ''; if ( ! wp_is_uuid( $profile_id ) && '' !== $config ) { // An unknown name keeps a non-empty marker so administrators are told the configuration is gone. $profile_id = AKLA_Admin::profile_id_by_slug( $config ) ?? $config; } $overrides = AKLA_Admin::preview_overrides( array( 'limit' => self::scalar_text( $attributes['limit'] ), 'layout' => strtolower( self::scalar_text( $attributes['layout'] ) ), 'headingLevel' => self::scalar_text( $attributes['heading_level'] ), 'style' => strtolower( self::scalar_text( $attributes['style'] ) ), ) ); $overrides['instance'] = self::scalar_text( $attributes['instance'] ); return self::render( $profile_id, '', self::context_purpose(), $overrides ); } /** * Render a dynamic block with editor attribution isolation. * * @param mixed $attributes Block attributes. * @param string $content Saved inner content (unused: the block is dynamic). * @param WP_Block $block Block instance; its supports (align, colours, spacing, typography) wrap the output. * * @return string Rendered output. */ public static function block( $attributes, $content = '', $block = null ) { $attributes = is_array( $attributes ) ? $attributes : array(); $classes = preg_split( '/\s+/', self::scalar_text( $attributes['className'] ?? '' ), -1, PREG_SPLIT_NO_EMPTY ); $overrides = AKLA_Admin::preview_overrides( array( 'limit' => $attributes['limit'] ?? null, 'layout' => $attributes['layout'] ?? null, 'headingLevel' => $attributes['headingLevel'] ?? null, 'style' => in_array( 'is-style-compact', $classes, true ) ? 'compact' : null, ) ); // Block supports (align, colours, spacing, typography, className) apply only to a real block render. if ( $block instanceof WP_Block ) { $overrides['block'] = true; } return self::render( self::scalar_text( $attributes['profileId'] ?? '' ), self::scalar_text( $attributes['widgetId'] ?? '' ), self::context_purpose(), $overrides ); } /** * Isolate draft previews, editor requests and administration from live sync. * * PREVIEW: post previews, administration screens (not admin-ajax), the plugin's editor preview * route, the core block renderer and editor-context REST loads. Everything else is LIVE, including * /wp/v2 content.rendered for headless sites and infinite-scroll admin-ajax requests. * * @return string Rendering purpose. */ public static function context_purpose() { $rest = defined( 'REST_REQUEST' ) && REST_REQUEST; return self::purpose_for( is_preview(), is_admin() && ! wp_doing_ajax(), $rest, $rest ? self::rest_route() : '', $rest && 'edit' === filter_input( INPUT_GET, 'context' ) ); } /** * Decide the purpose from request facts. * * @param bool $preview Whether this is a post preview. * @param bool $admin_screen Whether an administration screen (not admin-ajax) is rendering. * @param bool $rest Whether this is a REST request. * @param string $route Current REST route, with a leading slash. * @param bool $edit_context Whether the REST request asks for the editor (context=edit) representation. * @return string PREVIEW or LIVE. */ private static function purpose_for( $preview, $admin_screen, $rest, $route, $edit_context = false ) { if ( $preview || $admin_screen ) { return 'PREVIEW'; } if ( $rest && ( self::PREVIEW_ROUTE === $route || str_starts_with( $route, '/wp/v2/block-renderer/' ) || $edit_context ) ) { return 'PREVIEW'; } return 'LIVE'; } /** * Return the REST route of the current HTTP request. * * @return string Route such as /wp/v2/posts/1, or an empty string. */ private static function rest_route() { $wp = $GLOBALS['wp'] ?? null; $route = $wp instanceof WP && isset( $wp->query_vars['rest_route'] ) && is_string( $wp->query_vars['rest_route'] ) ? $wp->query_vars['rest_route'] : ''; return '' === $route ? '' : '/' . trim( strtolower( $route ), '/' ); } /** * Render one configuration in its selected language. * * @param mixed $profile_id Profile id. * @param mixed $widget_id Widget id. * @param mixed $purpose Purpose. * @param array $overrides Display overrides from AKLA_Admin::preview_overrides() (limit, layout, heading_level, style). * * @return string Rendered output. */ public static function render( $profile_id, $widget_id = '', $purpose = 'LIVE', $overrides = array() ) { $options = self::options( $overrides ); $profile_id = is_string( $profile_id ) ? $profile_id : ''; $profiles = get_option( 'akla_profiles', array() ); $profiles = is_array( $profiles ) ? $profiles : array(); $notices = self::admin_notices_allowed(); if ( ! wp_is_uuid( $profile_id ) || ! isset( $profiles[ $profile_id ] ) || ! is_array( $profiles[ $profile_id ] ) ) { $message = '' === $profile_id ? __( 'Choose a saved configuration', 'actikidz-local-activities' ) : __( 'This configuration is no longer available. Choose another one.', 'actikidz-local-activities' ); return $notices ? self::admin_notice_block( self::admin_notice( $message ) ) : '

' . esc_html__( 'Courses are temporarily unavailable. Please try again later.', 'actikidz-local-activities' ) . '

'; } $state = $notices ? AKLA_Client::status()['state'] : ''; if ( ! AKLA_Client::connected() ) { return $notices ? self::admin_notice_block( self::admin_notice( self::connection_notice( $state ) ) ) : ''; } // Notices are written in the administrator's language, before switching to the component language. $notice = $notices && in_array( $state, array( 'reconnect', 'origin_changed', 'check' ), true ) ? self::admin_notice( self::connection_notice( $state ) ) : ''; $unavailable = $notices ? self::admin_notice( __( 'Embeddable courses are not available for this city yet. Choose another city in settings.', 'actikidz-local-activities' ) ) : ''; $profile = AKLA_Admin::normalize_profile( $profiles[ $profile_id ] ); // Keep the saved identity while resolving the effective query for rendering and refresh. $profile['id'] = $profile_id; $context = AKLA_Query_Context::resolve( $profile, $options, $widget_id ); $profile = $context['profile']; $widget_id = $context['widget']; $language = $context['language']; $options['language'] = $language; $options['browse_mode'] = $options['browse_mode'] ?? ( $profile['browse_mode'] ?? 'directory' ); unset( $options['instance'] ); // The resolved UUID is signed; never derive it a second time. // No request-wide locale switch: module strings follow the component language through AKLA_Translations, // which overrides WordPress translations only where the component language differs from the site language. $previous_language = AKLA_Translations::language( $language ); try { wp_enqueue_style( 'akla-public' ); wp_enqueue_style( 'akla-filters-style' ); if ( 'LIVE' === $purpose && null === self::$request_args && 'server' !== ( $profile['delivery'] ?? 'dynamic' ) ) { return self::shell( $profile_id, $widget_id, $language, $options ); } self::$response_meta = array( 'state' => 'ready', 'serverTime' => time(), ); $result = AKLA_Client::catalog( $profile, $widget_id, $purpose ); wp_enqueue_style( 'akla-public' ); wp_enqueue_script( 'akla-pagination' ); if ( is_wp_error( $result ) ) { self::$response_meta = array( 'state' => 'unavailable', 'status' => $result->get_error_data()['status'] ?? 503, 'retryAfter' => $result->get_error_data()['retryAfter'] ?? 60, 'serverTime' => time(), ); if ( '' === $notice && $notices ) { $notice = self::admin_notice( __( 'ActiKidz did not respond in time, so courses are not shown yet. The plugin keeps retrying in the background. Only administrators see this message.', 'actikidz-local-activities' ) ); } $message = $notice . '

' . esc_html( __( 'Courses are temporarily unavailable. Please try again later.', 'actikidz-local-activities' ) ) . '

'; $message .= ''; if ( ! empty( $profile['fallback_text'] ) ) { $message .= '

' . esc_html( $profile['fallback_text'] ) . '

'; } if ( ! empty( $profile['fallback_url'] ) ) { $message .= '' . esc_html__( 'Browse alternatives', 'actikidz-local-activities' ) . ''; } if ( 'LIVE' === $purpose && ! empty( $profile['hide_empty'] ) ) { $output = ''; } elseif ( ! empty( $options['fragment'] ) ) { $output = $message; } else { $output = '
'error', 'class' => 'ak-local-activities', 'lang' => $language, ), ! empty( $options['block'] ) ) . '>' . $message . '
'; } } else { if ( '' === $notice && empty( $result['previewItems'] ) && 'NOT_AVAILABLE' === ( $result['sourceState'] ?? '' ) ) { $notice = $unavailable; } // Visitor filters: validated against this snapshot's own facets and age ranges, then read as a separate snapshot. $state = self::filter_state( $result, $profile, $widget_id, $purpose, 'LIVE' === $purpose ? self::request_args() : array(), 'compact' === ( $options['style'] ?? '' ) ); $display = $result; $prefix = self::param_prefix( $widget_id, $profile['query'] ); $page = self::request_args()[ $prefix . '_p' ] ?? ''; if ( null === $state && is_string( $page ) && '' !== $page ) { $paged = AKLA_Client::catalog( $profile, $widget_id, $purpose, array( 'pageToken' => $page ) ); if ( is_wp_error( $paged ) ) { $display['previewItems'] = array(); self::$response_meta = array( 'state' => 'unavailable', 'status' => $paged->get_error_data()['status'] ?? 503, 'retryAfter' => $paged->get_error_data()['retryAfter'] ?? 60, 'serverTime' => time(), ); } else { $display = $paged; } } if ( null !== $state ) { if ( 'LIVE' === $purpose && wp_is_uuid( $profile_id ) && wp_is_uuid( $widget_id ) ) { $state['token'] = self::module_token( $profile_id, $widget_id, $options ); $state['endpoint'] = rest_url( self::RESULTS_ROUTE ); } $page = self::request_args()[ $state['prefix'] . '_p' ] ?? ''; if ( is_string( $page ) && '' !== $page ) { $state['filters']['pageToken'] = $page; } if ( $state['active'] || ! empty( $state['filters']['pageToken'] ) ) { $filtered = AKLA_Client::catalog( $profile, $widget_id, $purpose, $state['filters'] ); if ( is_wp_error( $filtered ) ) { $state['unavailable'] = true; self::$response_meta = array( 'state' => 'unavailable', 'status' => $filtered->get_error_data()['status'] ?? 503, 'retryAfter' => $filtered->get_error_data()['retryAfter'] ?? 60, 'serverTime' => time(), ); $display['previewItems'] = array(); // Never label the previous query as the failed new selection. } else { $display = $filtered; } } if ( ! empty( $state['invalid'] ) ) { $display['previewItems'] = array(); $state['unavailable'] = true; self::$response_meta = array( 'state' => 'unavailable', 'status' => 400, 'serverTime' => time(), ); } } $options['notice'] = $notice; $options['structured_data'] = true; $options['filters'] = $state; $options['module'] = self::module_token( $profile_id, $widget_id, $options ); $options['prefix'] = self::param_prefix( $widget_id, $profile['query'] ); self::$response_meta += array( 'asOf' => $display['asOf'] ?? null, 'expiresAt' => self::expiry( $display ), ); $output = self::html( $display, $profile, $purpose, $options ); } } finally { AKLA_Translations::language( $previous_language ); } if ( '' === $output && '' !== $notice ) { return ! empty( $options['fragment'] ) ? $notice : self::admin_notice_block( $notice ); } self::$response_meta['serverTime'] = time(); return $output; } /** * Use the earliest refresh or hard expiry boundary supplied by the service. * * @param array $result Catalog response. * @return string|null Effective UTC expiry. */ private static function expiry( array $result ) { $times = array(); foreach ( array( 'snapshotRefreshAt', 'contentExpiresAt' ) as $key ) { $value = is_string( $result[ $key ] ?? null ) ? strtotime( $result[ $key ] ) : false; if ( false !== $value ) { $times[] = $value; } } return $times ? gmdate( 'c', min( $times ) ) : null; } /** * Build escaped, text-only course cards and optional links. * * @param mixed $result Result. * @param mixed $profile Profile. * @param string $purpose Rendering purpose. * @param array $options Display overrides (limit, layout, heading_level, style), block flag and administrator notice. * * @return string Rendered output. */ public static function html( $result, $profile, $purpose = 'LIVE', $options = array() ) { $profile = AKLA_Admin::normalize_profile( is_array( $profile ) ? $profile : array() ); // render() passes the visitor filter state it built (or null); direct callers get the bar without a selection. $state = is_array( $options ) && array_key_exists( 'filters', $options ) ? $options['filters'] : false; $options = self::options( $options ) + array( 'notice' => is_array( $options ) && is_string( $options['notice'] ?? null ) ? $options['notice'] : '' ); $items = is_array( $result['previewItems'] ?? null ) ? array_values( $result['previewItems'] ) : array(); if ( isset( $options['limit'] ) ) { // Defensive bound for direct html() callers; render() already requests this effective limit. $items = array_slice( $items, 0, $options['limit'] ); } if ( false === $state ) { $state = self::filter_state( is_array( $result ) ? $result : array(), $profile, $profile['widget_id'] ?? '', $purpose, array(), 'compact' === ( $options['style'] ?? '' ) ); } $state = is_array( $state ) ? $state : null; // A visitor's filter that matches nothing keeps the module (and its filters) visible. if ( empty( $items ) && 'LIVE' === $purpose && ! empty( $profile['hide_empty'] ) && empty( $state['active'] ) ) { return ''; } $language = $options['language'] ?? $profile['query']['language'] ?? 'en'; // Scope the component language here too, so direct callers get the same strings, dates and names as render(). $previous = AKLA_Translations::language( $language ); try { return self::module_html( $result, $profile, $purpose, $options, $items, $language, $state ); } finally { AKLA_Translations::language( $previous ); } } /** * Build the module for html(), inside the component language scope. * * @param array $result Catalog result. * @param array $profile Normalized profile. * @param string $purpose Rendering purpose. * @param array $options Valid display options plus the administrator notice. * @param array $items Courses to show. * @param string $language Component language. * @param array|null $state Visitor filter state (filter_state()), or null without a filter bar. * @return string Rendered output. */ private static function module_html( $result, $profile, $purpose, $options, $items, $language, $state = null ) { $layout = $options['layout'] ?? ( in_array( $profile['layout'] ?? '', array( 'cards', 'list' ), true ) ? $profile['layout'] : 'cards' ); $level = $options['heading_level'] ?? self::heading_level( $profile['heading_level'] ?? null ); $compact = 'compact' === ( $options['style'] ?? '' ); $block = ! empty( $options['block'] ); $links = $profile['links']; $cards = ''; $count = 0; $events = array(); $schema = 'LIVE' === $purpose && ! empty( $options['structured_data'] ); foreach ( $items as $item ) { if ( ! is_array( $item ) || empty( $item['courseId'] ) || empty( $item['title'] ) ) { continue; } ++$count; $offerings = isset( $item['offerings'] ) && is_array( $item['offerings'] ) ? array_values( $item['offerings'] ) : array(); $sessions = self::course_sessions( $item, $offerings ); $cards .= '
'; $cards .= self::card_html( $item, $offerings, $sessions, $language, $level, $links, $compact ) . '
'; if ( $schema ) { array_push( $events, ...self::events( $item, $offerings, $sessions, $links ) ); } } $classes = array( 'ak-local-activities', 'ak-local-activities--' . $layout ); if ( $compact ) { $classes[] = $block ? 'is-style-compact' : 'ak-local-activities--compact'; } $attributes = array( 'class' => implode( ' ', $classes ), 'lang' => $language, 'aria-label' => __( 'Local children’s activities', 'actikidz-local-activities' ), 'data-akla-previous' => __( 'Previous', 'actikidz-local-activities' ), 'data-akla-next' => __( 'Next', 'actikidz-local-activities' ), 'data-akla-classes' => __( 'Classes', 'actikidz-local-activities' ), 'data-akla-sessions' => __( 'Sessions', 'actikidz-local-activities' ), ); if ( 'LIVE' === $purpose && empty( $options['fragment'] ) ) { $attributes['data-akla-expires'] = self::expiry( $result ); $attributes['data-akla-server-time'] = time(); $attributes['data-akla-error'] = __( 'Courses are temporarily unavailable. Please try again later.', 'actikidz-local-activities' ); wp_enqueue_script( 'akla-delivery' ); } $html = $options['notice']; if ( null !== $state ) { $attributes['data-akla-filterable'] = '1'; wp_enqueue_script( 'akla-filters' ); wp_enqueue_style( 'akla-filters-style' ); $html .= self::filters_html( $state, $result, $language ); } if ( ! empty( $result['_stale'] ) ) { $html .= '

' . esc_html__( 'Showing the last checked information while the service refreshes.', 'actikidz-local-activities' ) . '

'; } if ( null !== $state ) { $html .= self::filter_status_html( $state, $result, $count ); } if ( empty( $items ) ) { $message = 'NOT_AVAILABLE' === ( $result['sourceState'] ?? '' ) ? __( 'Embeddable courses are not available for this city yet.', 'actikidz-local-activities' ) : __( 'No courses match these filters right now.', 'actikidz-local-activities' ); if ( ! empty( $result['courseCount'] ) || 'UNKNOWN' === ( $result['sourceState'] ?? '' ) || ( 'NOT_AVAILABLE' !== ( $result['sourceState'] ?? '' ) && 'UNKNOWN' === ( $result['freshness'] ?? '' ) ) ) { $message = __( 'Course information is being refreshed. Please try again later.', 'actikidz-local-activities' ); } $html .= '

' . esc_html( $message ) . '

'; } $html .= '
' . $cards . '
'; $html .= self::course_navigation( $result, $options ); if ( ! empty( $items ) ) { $html .= '

' . esc_html__( 'Schedules and prices may change. Confirm details with the course provider.', 'actikidz-local-activities' ) . '

'; } if ( ! empty( $profile['brand'] ) ) { $html .= '

' . esc_html__( 'Course information by ActiKidz', 'actikidz-local-activities' ) . '

'; $html .= '

' . esc_html__( 'See more activities on ActiKidz', 'actikidz-local-activities' ) . '

'; } if ( ! empty( $events ) ) { // JSON_HEX_TAG / JSON_HEX_AMP: no "" or markup can close the element from course data. $json = wp_json_encode( array( '@context' => 'https://schema.org', '@graph' => $events, ), JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $html .= is_string( $json ) ? wp_get_inline_script_tag( $json, array( 'type' => 'application/ld+json' ) ) : ''; } // The public results route returns only the module's content; the page keeps its own wrapper (block supports). return ! empty( $options['fragment'] ) ? $html : '
' . $html . '
'; } /** * Schema.org Events for one course: one per class (legacy courses: one for the course). * * Only facts the module also shows; prices only when known with a billing basis, or free. * * @param array $item Course facts. * @param array $offerings Classes of this course. * @param array $sessions All sessions of this course (legacy courses). * @param array $links links.course / links.source settings. * @return array[] Event objects. */ private static function events( $item, $offerings, $sessions, $links ) { if ( empty( $offerings ) ) { $offerings = array( array_merge( array_intersect_key( $item, array_flip( array( 'venue', 'price', 'priceMax', 'currency', 'priceUnit', 'timeZone' ) ) ), array( 'occurrences' => $sessions ) ), ); } $title_url = empty( $links['course'] ) ? null : self::course_url( $item, $offerings ); $organizer = null; if ( ! empty( $item['sourceName'] ) && is_string( $item['sourceName'] ) ) { $organizer = array( '@type' => 'Organization', 'name' => $item['sourceName'], ); $source = empty( $links['source'] ) ? null : self::registration_url( $item['sourceUrl'] ?? null ); if ( null !== $source ) { $organizer['url'] = $source; } } $events = array(); foreach ( $offerings as $offering ) { $event = is_array( $offering ) ? self::event( $item, $offering, $organizer ) : null; if ( null === $event ) { continue; } $url = empty( $links['course'] ) ? null : ( self::registration_url( $offering['registrationUrl'] ?? null ) ?? $title_url ); if ( null !== $url ) { $event['url'] = $url; } $events[] = $event; } return $events; } /** * One schema.org Event for a class, or null without a usable session. * * @param array $item Course facts. * @param array $offering Class facts with occurrences. * @param array|null $organizer Organization object. * @return array|null Event. */ private static function event( $item, $offering, $organizer ) { $times = array(); foreach ( is_array( $offering['occurrences'] ?? null ) ? $offering['occurrences'] : array() as $session ) { $time = is_array( $session ) ? self::session_times( $session ) : null; if ( null !== $time ) { $times[] = $time; } } if ( empty( $times ) ) { return null; } usort( $times, static function ( $a, $b ) { return $a['start'] <=> $b['start']; } ); $next = $times[0]; $event = array( '@type' => 'Event', 'name' => (string) $item['title'], 'startDate' => self::iso( $next['start'], $next['start_zone'] ), 'endDate' => self::iso( $next['end'], $next['end_zone'] ), 'eventAttendanceMode' => 'https://schema.org/OfflineEventAttendanceMode', ); if ( count( $times ) > 1 ) { $days = array(); $starts = array(); $ends = array(); $last = $next; foreach ( $times as $time ) { $local = ( new DateTimeImmutable( '@' . $time['start'] ) )->setTimezone( $time['start_zone'] ); $days[] = 'https://schema.org/' . $local->format( 'l' ); $starts[] = $local->format( 'H:i:s' ); $ends[] = ( new DateTimeImmutable( '@' . $time['end'] ) )->setTimezone( $time['end_zone'] )->format( 'H:i:s' ); $last = $time['end'] > $last['end'] ? $time : $last; } $schedule = array( '@type' => 'Schedule', 'startDate' => ( new DateTimeImmutable( '@' . $next['start'] ) )->setTimezone( $next['start_zone'] )->format( 'Y-m-d' ), 'endDate' => ( new DateTimeImmutable( '@' . $last['end'] ) )->setTimezone( $last['end_zone'] )->format( 'Y-m-d' ), 'byDay' => array_values( array_unique( $days ) ), 'scheduleTimezone' => $next['zone'], ); // A single start and end time only when every session shares it. if ( 1 === count( array_unique( $starts ) ) && 1 === count( array_unique( $ends ) ) ) { $schedule['startTime'] = $starts[0]; $schedule['endTime'] = $ends[0]; } $event['eventSchedule'] = $schedule; } $venue = is_string( $offering['venue'] ?? null ) && '' !== $offering['venue'] ? $offering['venue'] : ( is_string( $item['venue'] ?? null ) ? $item['venue'] : '' ); $address = array_filter( array( '@type' => 'PostalAddress', 'addressLocality' => is_string( $item['city'] ?? null ) ? $item['city'] : '', 'addressRegion' => is_string( $item['province'] ?? null ) ? $item['province'] : '', 'addressCountry' => 'CA', ) ); $location = array( '@type' => 'Place' ); if ( '' !== $venue ) { $location['name'] = $venue; } $location['address'] = $address; $event['location'] = $location; if ( null !== $organizer ) { $event['organizer'] = $organizer; } $offer = self::offer( $offering ); if ( null !== $offer ) { $event['offers'] = $offer; if ( 0 === ( $offer['price'] ?? null ) ) { $event['isAccessibleForFree'] = true; } } return $event; } /** * A session's instants and display zones, or null when incomplete. * * @param array $session Session facts. * @return array|null start / end timestamps, their display zones and the IANA zone. */ private static function session_times( $session ) { if ( empty( $session['startsAt'] ) || empty( $session['timeZone'] ) || ! is_string( $session['startsAt'] ) || ! is_string( $session['timeZone'] ) ) { return null; } try { $start = ( new DateTimeImmutable( $session['startsAt'] ) )->getTimestamp(); $end = ( new DateTimeImmutable( is_string( $session['endsAt'] ?? null ) ? $session['endsAt'] : $session['startsAt'] ) )->getTimestamp(); return array( 'start' => $start, 'end' => $end, 'start_zone' => self::display_timezone( $session['timeZone'], $start ), 'end_zone' => self::display_timezone( $session['timeZone'], $end ), 'zone' => $session['timeZone'], ); } catch ( Exception $error ) { return null; } } /** * Schema.org offer for a class price that is known and unambiguous (billing basis given, or free). * * @param array $offering Class facts. * @return array|null Offer or AggregateOffer. */ private static function offer( $offering ) { if ( ! isset( $offering['price'] ) || ! is_numeric( $offering['price'] ) || empty( $offering['currency'] ) || ! is_string( $offering['currency'] ) ) { return null; } $price = (float) $offering['price']; $max = isset( $offering['priceMax'] ) && is_numeric( $offering['priceMax'] ) && (float) $offering['priceMax'] > $price ? (float) $offering['priceMax'] : null; if ( 0.0 === $price && null === $max ) { return array( '@type' => 'Offer', 'price' => 0, 'priceCurrency' => $offering['currency'], ); } if ( empty( $offering['priceUnit'] ) ) { return null; // A listed figure without a billing basis is not a price for the event. } if ( null !== $max ) { return array( '@type' => 'AggregateOffer', 'lowPrice' => self::amount( $price ), 'highPrice' => self::amount( $max ), 'priceCurrency' => $offering['currency'], ); } return array( '@type' => 'Offer', 'price' => self::amount( $price ), 'priceCurrency' => $offering['currency'], ); } /** * A price as a JSON number: whole amounts as integers, others to two decimals. * * @param float $amount Amount. * @return int|float Amount. */ private static function amount( $amount ) { $amount = round( $amount, 2 ); return floor( $amount ) === $amount ? (int) $amount : $amount; } /** * Build one course card body: title, facts, classes and sessions, and source. * * @param array $item Course facts. * @param array $offerings Classes of this course. * @param array $sessions All sessions of this course. * @param string $language Component language. * @param int $level Course title heading level (2–6). * @param array $links links.course / links.source settings. * @param bool $compact Whether only the title, next session, place, price and source are shown. * @return string Escaped HTML. */ private static function card_html( $item, $offerings, $sessions, $language, $level, $links, $compact ) { $html = ''; $title = esc_html( $item['title'] ); $url = empty( $links['course'] ) ? null : self::course_url( $item, $offerings ); if ( null !== $url ) { $html .= '' . $title . ''; } else { $html .= $title; } $html .= ''; $multiple = count( $offerings ) > 1; $next = self::nearest_session( $sessions ); $venues = array_values( array_unique( array_filter( array_column( $offerings, 'venue' ) ) ) ); $venue = $venues[0] ?? ( $item['venue'] ?? '' ); if ( count( $venues ) > 1 ) { /* translators: %s: number of distinct venues (two or more). */ $venue = sprintf( _n( '%s location', '%s locations', count( $venues ), 'actikidz-local-activities' ), count( $venues ) ); } // The course-level "When" names the time zone only when it is not the site's; class and session lines repeat it only when it differs from the course. $zone = $next['timeZone'] ?? null; $place = implode( ', ', array_filter( array( $item['city'] ?? '', $item['province'] ?? '' ) ) ); $fields = array( __( 'When', 'actikidz-local-activities' ) => self::date_label( $next ?? array(), $language ), __( 'Dates', 'actikidz-local-activities' ) => ! $compact && 1 === count( $offerings ) ? self::period_label( $offerings[0], $language ) : '', __( 'Where', 'actikidz-local-activities' ) => esc_html( implode( ' · ', array_filter( array( $venue, $place ) ) ) ), __( 'Age', 'actikidz-local-activities' ) => esc_html( self::age_label( $item ) ), __( 'Category', 'actikidz-local-activities' ) => $compact ? '' : esc_html( self::category_label( $item ) ), __( 'Price', 'actikidz-local-activities' ) => esc_html( self::course_price_label( $offerings, $item ) ), ); $html .= '
'; // Values are escaped HTML (dates carry
'; $html .= self::freshness_html( $item, $language ); if ( $compact ) { foreach ( $offerings as $offering ) { $html .= self::offering_cta( $item, $offering, $links ); } return $html . self::source_html( $item, $links ) . self::license_html( $item ); } if ( ! empty( $offerings ) ) { if ( $multiple ) { /* translators: %s: number of available classes. */ $summary = sprintf( _n( 'View %s class', 'View %s classes', count( $offerings ), 'actikidz-local-activities' ), count( $offerings ) ); } else { $summary = self::sessions_count_label( count( $offerings[0]['occurrences'] ) ); } $html .= '
' . esc_html( $summary ) . ''; foreach ( $offerings as $index => $offering ) { if ( $multiple ) { /* translators: %s: class number within this course. */ $class = sprintf( __( 'Class %s', 'actikidz-local-activities' ), $index + 1 ); $first = self::nearest_session( $offering['occurrences'] ); $html .= '
' . esc_html( $class . ( empty( $offering['venue'] ) ? '' : ' · ' . $offering['venue'] ) ) . ''; $html .= '' . self::date_label( $first ?? array(), $language, $zone ) . ''; $period = self::period_label( $offering, $language ); if ( '' !== $period ) { $html .= '' . $period . ''; } $html .= '' . esc_html( self::sessions_count_label( count( $offering['occurrences'] ) ) ) . ''; $html .= '' . esc_html( self::price_label( $offering ) ) . ''; $html .= self::offering_cta( $item, $offering, $links ) . self::sessions_html( $offering['occurrences'], $language, false, $zone ) . '
'; } else { $html .= '
' . self::offering_cta( $item, $offering, $links ) . self::sessions_html( $offering['occurrences'], $language, false, $zone ) . '
'; } } $html .= '
'; } elseif ( ! empty( $item['occurrences'] ) && is_array( $item['occurrences'] ) && count( $item['occurrences'] ) <= 512 ) { $html .= '
' . esc_html__( 'View all sessions in this period', 'actikidz-local-activities' ) . ''; $html .= self::sessions_html( $item['occurrences'], $language, true, $zone ) . '
'; } return $html . self::source_html( $item, $links ) . self::license_html( $item ); } /** * Number of sessions in the selected period, with the plural form of the component language. * * @param int $count Sessions. * @return string Plain label. */ private static function sessions_count_label( $count ) { /* translators: %s: number of sessions in the selected period. */ return sprintf( _n( '%s session in this period', '%s sessions in this period', $count, 'actikidz-local-activities' ), $count ); } /** * Select a safe per-class action while respecting the course link switch. * * @param array $item Course facts. * @param array $offering Class facts. * @param array $links Link settings. * @return mixed Result documented by the caller. */ private static function offering_cta( $item, $offering, $links ) { if ( empty( $links['course'] ) ) { return ''; } $url = self::registration_url( $offering['registrationUrl'] ?? '' ); $label = __( 'View or register for this class', 'actikidz-local-activities' ); if ( null === $url ) { $url = self::registration_url( $item['registrationUrl'] ?? '' ); $label = __( 'Choose this class on the provider website', 'actikidz-local-activities' ); } if ( null === $url ) { $url = self::detail_url( $item ); $label = __( 'View course details', 'actikidz-local-activities' ); } return null === $url ? '' : '

' . esc_html( $label ) . '

'; } /** * Display source verification time separately from local cache age. * * @param array $item Course facts. * @param string $language Module language. * @return mixed Result documented by the caller. */ private static function freshness_html( $item, $language ) { $checked = is_string( $item['lastSuccessfulCheckAt'] ?? null ) ? strtotime( $item['lastSuccessfulCheckAt'] ) : false; $text = __( 'Source check time is unavailable.', 'actikidz-local-activities' ); if ( false !== $checked ) { /* translators: %s: the last source check in site time. */ $text = sprintf( __( 'Source last checked: %s', 'actikidz-local-activities' ), self::local_date( self::formats( $language )['date'] . ' H:i T', $checked, wp_timezone(), $language ) ); } if ( 'STALE' === ( $item['freshness'] ?? '' ) ) { $text .= ' ' . __( 'Confirm this class with the provider before registering.', 'actikidz-local-activities' ); } return '

' . esc_html( $text ) . '

'; } /** * Render a cache-safe frame and a complete no-script browsing link. * * @param string $profile Configuration UUID. * @param string $widget Widget UUID. * @param string $language Resolved module language. * @param array $options Validated display options. * @return mixed Result documented by the caller. */ private static function shell( $profile, $widget, $language, $options ) { $permalink = get_permalink( get_queried_object_id() ); if ( is_string( $permalink ) && '' !== $permalink ) { $options['return_path'] = wp_make_link_relative( $permalink ); } $token = self::module_token( $profile, $widget, $options ); $view = add_query_arg( 'akla_view', $token, home_url( '/' ) ); wp_enqueue_script( 'akla-delivery' ); return '
'ak-local-activities', 'lang' => $language, 'data-akla-delivery' => '1', 'data-akla-module' => $token, 'data-akla-endpoint' => rest_url( self::RESULTS_ROUTE ), 'data-akla-prefix' => self::param_prefix( $widget ), 'data-akla-loading' => __( 'Loading courses…', 'actikidz-local-activities' ), 'data-akla-previous' => __( 'Previous', 'actikidz-local-activities' ), 'data-akla-next' => __( 'Next', 'actikidz-local-activities' ), 'data-akla-classes' => __( 'Classes', 'actikidz-local-activities' ), 'data-akla-sessions' => __( 'Sessions', 'actikidz-local-activities' ), 'data-akla-error' => __( 'Courses are temporarily unavailable. Please try again later.', 'actikidz-local-activities' ), ), ! empty( $options['block'] ) ) . '>

' . esc_html__( 'Loading courses…', 'actikidz-local-activities' ) . '

' . esc_html__( 'Browse all courses on this site', 'actikidz-local-activities' ) . '

'; } /** * Render course-level navigation independently of class/session pagination. * * @param array $result Catalog response. * @param array $options Module reference and display options. * @return mixed Result documented by the caller. */ private static function course_navigation( $result, $options ) { if ( empty( $options['module'] ) || empty( $options['prefix'] ) ) { return ''; } $prefix = $options['prefix']; $args = self::request_args(); $current = $args[ $prefix . '_p' ] ?? ''; $next = $result['pagination']['nextPageToken'] ?? ''; $links = ''; if ( 'summary' === ( $options['browse_mode'] ?? '' ) ) { // The signed summary reference is accepted by the local view, which explicitly renders directory mode. return '

' . esc_html__( 'Browse all courses on this site', 'actikidz-local-activities' ) . '

'; } if ( is_string( $current ) && '' !== $current ) { $back = get_transient( 'akla_page_back_' . md5( $options['module'] . '|' . $current ) ); $previous = $args; unset( $previous[ $prefix . '_p' ] ); if ( is_string( $back ) && '' !== $back ) { $previous[ $prefix . '_p' ] = $back; } $links .= '' . esc_html__( 'Previous courses', 'actikidz-local-activities' ) . ''; } if ( ! empty( $result['pagination']['hasNext'] ) && is_string( $next ) && preg_match( '/^[A-Za-z0-9_.-]{1,1024}$/D', $next ) ) { $history_key = 'akla_page_back_' . md5( $options['module'] . '|' . $next ); $history = (array) get_option( 'akla_page_history', array() ); if ( ! isset( $history[ $history_key ] ) ) { $history[ $history_key ] = time(); foreach ( array_slice( array_keys( $history ), 0, max( 0, count( $history ) - 256 ) ) as $oldest ) { delete_transient( $oldest ); unset( $history[ $oldest ] ); } update_option( 'akla_page_history', $history, false ); } set_transient( $history_key, $current, 900 ); $args[ $prefix . '_p' ] = $next; $links .= '' . esc_html__( 'Next courses', 'actikidz-local-activities' ) . ''; } return '' === $links ? '' : ''; } /** A complete same-site HTML fallback for visitors without JavaScript. */ public static function public_view() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Public read-only signed reference, not a state-changing action. if ( ! isset( $_GET['akla_view'] ) ) { return; } $reference = is_string( $_GET['akla_view'] ) ? sanitize_text_field( wp_unslash( $_GET['akla_view'] ) ) : ''; $module = self::read_token( $reference ); nocache_headers(); header( 'Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0' ); header( 'X-Robots-Tag: noindex' ); if ( null === $module ) { status_header( 400 ); wp_die( esc_html__( 'The request is not supported.', 'actikidz-local-activities' ) ); } self::$request_args = self::clean_args( wp_unslash( $_GET ) ); // Keep the signed reference in every GET form and course navigation link. self::$request_args['akla_view'] = $reference; // phpcs:enable WordPress.Security.NonceVerification.Recommended $module['options']['browse_mode'] = 'directory'; AKLA_Client::serve_visitor_route( true ); $html = self::render( $module['profile'], $module['widget'], 'LIVE', $module['options'] ); $back = '

' . esc_html__( 'Back to the site', 'actikidz-local-activities' ) . '

'; status_header( 200 ); header( 'Content-Type: text/html; charset=utf-8' ); echo '' . esc_html__( 'Local children’s activities', 'actikidz-local-activities' ) . ''; wp_head(); echo '
' . $back . $html . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both fragments escaped above/by render(); preserve JSON-LD and form attributes. wp_footer(); echo ''; exit; } /** * Name the course source; it links to the provider only when links.source is on. * * @param array $item Course facts. * @param array $links links.course / links.source settings. * @return string Escaped HTML, or an empty string without a source. */ private static function source_html( $item, $links ) { if ( empty( $item['sourceName'] ) || ! is_string( $item['sourceName'] ) ) { return ''; } $html = '

' . esc_html__( 'Source:', 'actikidz-local-activities' ) . ' '; $source = esc_html( $item['sourceName'] ); $source_url = ! empty( $links['source'] ) ? self::registration_url( $item['sourceUrl'] ?? null ) : null; if ( null !== $source_url ) { $html .= '' . $source . ''; } else { $html .= $source; } return $html . '

'; } /** * Open-data licence attribution (OGL / CC BY 4.0) for courses whose source publishes under an open licence. * * The licences require it wherever the data is shown, so it does not depend on the links setting. The * attribution statement is printed verbatim (it is the wording the licence prescribes), never translated. * * @param array $item Course facts. * @return string Escaped HTML, or an empty string when the source has no open licence. */ private static function license_html( $item ) { $license = $item['dataLicense'] ?? null; if ( ! is_array( $license ) ) { return ''; } $name = is_string( $license['name'] ?? null ) ? trim( $license['name'] ) : ''; $attribution = is_string( $license['attribution'] ?? null ) ? trim( $license['attribution'] ) : ''; if ( '' === $name && '' === $attribution ) { return ''; } $html = '

'; if ( '' !== $attribution ) { $html .= '' . esc_html( $attribution ) . ''; } if ( '' !== $name ) { $url = self::registration_url( $license['url'] ?? null ); $label = null !== $url ? '' . esc_html( $name ) . '' : esc_html( $name ); /* translators: %s: name of the open-data licence, e.g. Open Government Licence - City of Calgary. */ $html .= ( '' !== $attribution ? ' ' : '' ) . '' . sprintf( esc_html__( 'Data licence: %s', 'actikidz-local-activities' ), $label ) . ''; } return $html . '

'; } /** * Choose the course title target: the official registration page first, then the ActiKidz course page. * * A course with one class uses that class's registration page when it has one. * * @param array $item Course facts. * @param array $offerings Classes of this course. * @return string|null Target URL, or null for plain text. */ private static function course_url( $item, $offerings ) { $candidates = array(); if ( 1 === count( $offerings ) && is_array( $offerings[0] ) ) { $candidates[] = $offerings[0]['registrationUrl'] ?? null; } $candidates[] = $item['registrationUrl'] ?? null; foreach ( $candidates as $candidate ) { $url = self::registration_url( $candidate ); if ( null !== $url ) { return $url; } } return self::detail_url( $item ); } /** * Accept only a plain https page address: no credentials, control characters or other schemes. * * Structural checks only; no DNS lookup happens while a page renders. * * @param mixed $url Candidate address from the service. * @return string|null The address, or null when it is not safe to link. */ private static function registration_url( $url ) { if ( ! is_string( $url ) || '' === $url || strlen( $url ) > 2048 || preg_match( '/[\s\x00-\x1f\x7f\\\\]/', $url ) ) { return null; } if ( wp_kses_bad_protocol( $url, array( 'https' ) ) !== $url ) { return null; } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || 'https' !== strtolower( $parts['scheme'] ?? '' ) || empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || false !== strpbrk( $parts['host'], ':#?[]@' ) || ! str_starts_with( strtolower( $url ), 'https://' ) ) { return null; } return $url; } /** * All sessions of a course, from its classes or legacy occurrence list. * * An empty occurrence list falls back to the course's own next session: the service sends `occurrences: []` * when a source does not share per-session facts, while startsAt/endsAt/timeZone still name the next session. * * @param array $item Course facts. * @param array $offerings Classes of this course. * @return array Sessions. */ private static function course_sessions( $item, $offerings ) { if ( ! empty( $offerings ) ) { $lists = array_filter( array_column( $offerings, 'occurrences' ), 'is_array' ); return empty( $lists ) ? array() : array_merge( ...array_values( $lists ) ); } if ( ! empty( $item['occurrences'] ) && is_array( $item['occurrences'] ) ) { return $item['occurrences']; } return empty( $item['startsAt'] ) ? array() : array( $item ); } /** * Read this request's query arguments for visitor filters: the page address, or the public results route's query. * * @return array Argument name => text or list of texts (clean_args()). */ private static function request_args() { if ( null !== self::$request_args ) { return self::$request_args; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only public display state from the page address (nothing is changed or stored); clean_args() keeps plain names and sanitized short text only. return self::clean_args( isset( $_GET ) && is_array( $_GET ) ? wp_unslash( $_GET ) : array() ); } /** * Keep plain query arguments: names of letters, digits, "_" and "-", values as short sanitized text or lists of it. * * @param mixed $raw Raw arguments. * @return array Argument name => text or list of texts; at most MAX_QUERY_ARGS arguments. */ private static function clean_args( $raw ) { $args = array(); foreach ( is_array( $raw ) ? $raw : array() as $name => $value ) { if ( count( $args ) >= self::MAX_QUERY_ARGS ) { break; } if ( ! is_string( $name ) || ! preg_match( '/^[A-Za-z0-9_-]{1,64}$/D', $name ) ) { continue; } if ( is_array( $value ) ) { if ( ! array_is_list( $value ) ) { continue; } $list = array(); foreach ( array_slice( $value, 0, self::MAX_QUERY_ARGS ) as $entry ) { if ( is_scalar( $entry ) ) { $list[] = substr( sanitize_text_field( (string) $entry ), 0, 200 ); } } $args[ $name ] = $list; } elseif ( is_scalar( $value ) ) { $args[ $name ] = substr( sanitize_text_field( (string) $value ), 0, 'akla_view' === $name ? 1500 : ( str_ends_with( $name, '_p' ) ? 1024 : 200 ) ); } } return $args; } /** * Query argument prefix of one module, so several modules on a page filter independently. * * @param mixed $widget_id Module (widget) id. * @param array $query Configured catalog query (identifies modules rendered without a widget id). * @return string Such as akf1a2b3c4d: "akf" and the first 8 hex digits of the widget id. */ public static function param_prefix( $widget_id, $query = array() ) { $hex = is_string( $widget_id ) && wp_is_uuid( $widget_id ) ? str_replace( '-', '', strtolower( $widget_id ) ) : md5( (string) wp_json_encode( $query ) ); return 'akf' . substr( $hex, 0, 8 ); } /** * Values of a list argument given as repeated name[] fields or as one comma-separated value. * * @param mixed $value Argument value. * @return string[] Non-empty values. */ private static function arg_list( $value ) { $values = is_array( $value ) ? $value : ( is_string( $value ) ? explode( ',', $value ) : array() ); $list = array(); foreach ( array_slice( $values, 0, self::MAX_QUERY_ARGS ) as $entry ) { $entry = is_string( $entry ) ? trim( $entry ) : ''; if ( '' !== $entry ) { $list[] = $entry; } } return $list; } /** * Visitor-selected weekdays (1 = Monday … 7 = Sunday): a preset button, or the day checkboxes. * * A preset sets the days outright (its button submits "none" while it is the current selection), so a * shared or reloaded address keeps meaning the same days. * * @param array $args Query arguments. * @param string $prefix Module prefix. * @return int[] Sorted distinct days. */ private static function selected_weekdays( $args, $prefix ) { $preset = $args[ $prefix . '_dp' ] ?? ''; $presets = self::weekday_presets(); if ( is_string( $preset ) && isset( $presets[ $preset ] ) ) { return $presets[ $preset ]; } if ( 'none' === $preset ) { return array(); } $days = array(); foreach ( self::arg_list( $args[ $prefix . '_d' ] ?? null ) as $day ) { if ( preg_match( '/^[1-7]$/D', $day ) ) { $days[ (int) $day ] = true; } } $days = array_keys( $days ); sort( $days ); return $days; } /** * Visitor filter bar state for one module, or null when the bar is not shown. * * The bar shows for at least FILTER_MIN_COURSES courses in the configured query (the snapshot's courseCount, * not the courses on screen), never in the compact style. Choices come only from the service: categories from * facets.categories (narrowed to the configured categories; shown from two choices), ages from ageRanges (not * when the configuration already fixes ages). Visitor values are accepted only when they are one of these * choices or a weekday 1–7; anything else is ignored. * * @param array $base Snapshot of the configured query (without visitor filters). * @param array $profile Normalized configuration. * @param mixed $widget_id Module (widget) id. * @param string $purpose Rendering purpose; previews show the bar disabled and ignore the address. * @param array $args Query arguments (request_args()). * @param bool $compact Whether the compact style is used. * @return array|null State. */ private static function filter_state( $base, $profile, $widget_id, $purpose, $args, $compact ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Retained for compatibility; compact now supports filters too. $total = isset( $base['courseCount'] ) && is_numeric( $base['courseCount'] ) ? (int) $base['courseCount'] : 0; if ( $total < self::FILTER_MIN_COURSES || ! in_array( $purpose, array( 'LIVE', 'PREVIEW' ), true ) ) { return null; } $query = is_array( $profile['query'] ?? null ) ? $profile['query'] : array(); $configured = AKLA_Client::configured_categories( $query ); $categories = array(); foreach ( is_array( $base['facets']['categories'] ?? null ) ? $base['facets']['categories'] : array() as $facet ) { if ( is_array( $facet ) && is_string( $facet['code'] ?? null ) && preg_match( '/^[a-z0-9_-]{1,64}$/D', $facet['code'] ) && is_string( $facet['label'] ?? null ) && '' !== trim( $facet['label'] ) && ( empty( $configured ) || in_array( $facet['code'], $configured, true ) ) ) { $categories[ $facet['code'] ] = $facet['label']; } } if ( count( $categories ) < 2 ) { $categories = array(); // A single choice is no choice. } $ages = array(); if ( ! AKLA_Client::configured_age( $query ) ) { foreach ( is_array( $base['ageRanges'] ?? null ) ? $base['ageRanges'] : array() as $range ) { if ( is_array( $range ) && is_string( $range['value'] ?? null ) && preg_match( '/^[0-9A-Za-z+_-]{1,32}$/D', $range['value'] ) && is_string( $range['label'] ?? null ) && '' !== trim( $range['label'] ) ) { $ages[ $range['value'] ] = $range['label']; } } } $prefix = self::param_prefix( $widget_id, $query ); $selected = array( 'categories' => array(), 'weekdays' => array(), 'ageRange' => '', ); $others = array(); foreach ( $args as $name => $value ) { if ( ! str_starts_with( $name, $prefix . '_' ) ) { $others[ $name ] = $value; } } if ( 'LIVE' === $purpose ) { $codes = self::arg_list( $args[ $prefix . '_c' ] ?? null ); foreach ( array_keys( $categories ) as $code ) { if ( in_array( $code, $codes, true ) ) { $selected['categories'][] = $code; } } $selected['weekdays'] = self::selected_weekdays( $args, $prefix ); $age = $args[ $prefix . '_a' ] ?? ''; $selected['ageRange'] = is_string( $age ) && isset( $ages[ $age ] ) ? $age : ''; } $filters = array(); if ( ! empty( $selected['categories'] ) ) { $filters['categories'] = $selected['categories']; } if ( ! empty( $selected['weekdays'] ) && count( $selected['weekdays'] ) < 7 ) { $filters['weekdays'] = $selected['weekdays']; // All seven days filter nothing. } if ( '' !== $selected['ageRange'] ) { $filters['ageRange'] = $selected['ageRange']; } return array( 'prefix' => $prefix, 'invalid' => ! empty( $args[ $prefix . '_a' ] ) && ( ! is_string( $args[ $prefix . '_a' ] ) || ! isset( $ages[ $args[ $prefix . '_a' ] ] ) ), 'categories' => $categories, 'ages' => $ages, 'selected' => $selected, 'filters' => $filters, 'active' => ! empty( $filters ), 'chosen' => ! empty( $selected['categories'] ) || ! empty( $selected['weekdays'] ) || '' !== $selected['ageRange'], 'others' => $others, 'disabled' => 'PREVIEW' === $purpose, ); } /** * Weekday names in the component language, Monday first: WordPress's own names ($wp_locale) when the component * language is the site language, otherwise the plugin's catalog (the same rule as month names in local_date()). * * @param string $language Component language. * @return array[] Day (1 = Monday … 7 = Sunday) => array( full name, short name ). */ private static function weekday_names( $language ) { global $wp_locale; $names = array(); if ( AKLA_Translations::matches_site( $language ) && $wp_locale instanceof WP_Locale ) { for ( $day = 1; $day <= 7; $day++ ) { $long = $wp_locale->get_weekday( $day % 7 ); $names[ $day ] = array( $long, $wp_locale->get_weekday_abbrev( $long ) ); } return $names; } $long = array( __( 'Sunday', 'actikidz-local-activities' ), __( 'Monday', 'actikidz-local-activities' ), __( 'Tuesday', 'actikidz-local-activities' ), __( 'Wednesday', 'actikidz-local-activities' ), __( 'Thursday', 'actikidz-local-activities' ), __( 'Friday', 'actikidz-local-activities' ), __( 'Saturday', 'actikidz-local-activities' ) ); $short = array( __( 'Sun', 'actikidz-local-activities' ), __( 'Mon', 'actikidz-local-activities' ), __( 'Tue', 'actikidz-local-activities' ), __( 'Wed', 'actikidz-local-activities' ), __( 'Thu', 'actikidz-local-activities' ), __( 'Fri', 'actikidz-local-activities' ), __( 'Sat', 'actikidz-local-activities' ) ); for ( $day = 1; $day <= 7; $day++ ) { $names[ $day ] = array( $long[ $day % 7 ], $short[ $day % 7 ] ); } return $names; } /** * The visitor filter bar: a GET form that works without JavaScript (filters.js submits it in place). * * @param array $state Filter state. * @param array $result Snapshot shown (its facets give the current course count per category). * @param string $language Component language. * @return string Escaped HTML. */ private static function filters_html( $state, $result, $language ) { $prefix = $state['prefix']; $selected = $state['selected']; $disabled = $state['disabled'] ? ' disabled' : ''; $counts = array(); foreach ( is_array( $result['facets']['categories'] ?? null ) ? $result['facets']['categories'] : array() as $facet ) { if ( is_array( $facet ) && is_string( $facet['code'] ?? null ) && is_int( $facet['count'] ?? null ) ) { $counts[ $facet['code'] ] = $facet['count']; } } $form = array( 'class' => 'ak-local-activities__filters', 'method' => 'get', 'aria-label' => __( 'Filter courses', 'actikidz-local-activities' ), 'data-akla-prefix' => $prefix, 'data-akla-error' => __( 'Results were not updated. Retry or cancel your changes.', 'actikidz-local-activities' ), 'data-akla-restart' => __( 'Return to first course page', 'actikidz-local-activities' ), 'data-akla-any-day' => __( 'Any day', 'actikidz-local-activities' ), 'data-akla-any-category' => __( 'All categories', 'actikidz-local-activities' ), 'data-akla-search-categories' => __( 'Search categories', 'actikidz-local-activities' ), 'data-akla-no-options' => __( 'No matching categories', 'actikidz-local-activities' ), ); if ( ! empty( $state['token'] ) && ! empty( $state['endpoint'] ) ) { $form['data-akla-module'] = $state['token']; $form['data-akla-endpoint'] = $state['endpoint']; // Shown in the status line by filters.js while it waits for the results route. $form['data-akla-loading'] = __( 'Loading courses…', 'actikidz-local-activities' ); } $html = '
'; if ( ! $state['disabled'] ) { $html .= self::hidden_inputs( $state['others'] ); } $dimensions = (int) ! empty( $selected['categories'] ) + (int) ! empty( $selected['weekdays'] ) + (int) ( '' !== $selected['ageRange'] ); $html .= '
' . esc_html__( 'Filters', 'actikidz-local-activities' ) . ( $dimensions ? ' · ' . $dimensions : '' ) . '
'; $html .= '
' . esc_html__( 'Filter courses', 'actikidz-local-activities' ) . '
'; $html .= '
' . esc_html__( 'Day of the week', 'actikidz-local-activities' ) . '
'; foreach ( self::weekday_names( $language ) as $day => $name ) { $html .= ''; } $html .= '
'; $presets = array( 'weekdays' => __( 'Weekdays', 'actikidz-local-activities' ), 'weekend' => __( 'Weekend', 'actikidz-local-activities' ), ); foreach ( $presets as $key => $label ) { // Same rule as the ActiKidz website: a preset is "on" only when the days are exactly that set; pressing it again clears. $values = self::weekday_presets()[ $key ]; $on = $values === $selected['weekdays']; $html .= ''; } $html .= '
'; if ( ! empty( $state['categories'] ) ) { $html .= '
' . esc_html__( 'Category', 'actikidz-local-activities' ) . '
'; foreach ( $state['categories'] as $code => $label ) { $html .= ''; } $html .= '
'; } if ( ! empty( $state['ages'] ) ) { $html .= '
'; } $html .= '
'; if ( ! $state['disabled'] ) { $html .= '' . esc_html__( 'Clear filters', 'actikidz-local-activities' ) . ''; } $html .= '
'; if ( ! empty( $selected['categories'] ) ) { $first = $state['categories'][ $selected['categories'][0] ] ?? $selected['categories'][0]; $html .= ''; } if ( ! empty( $selected['weekdays'] ) && ( self::weekday_presets()['weekend'] ?? array() ) !== $selected['weekdays'] ) { $names = self::weekday_names( $language ); $labels = array(); foreach ( $selected['weekdays'] as $day ) { $labels[] = $names[ $day ][1]; } $html .= ''; } return $html . '
'; } /** * Result count, or why filtering did not apply. * * @param array $state Filter state. * @param array $result Snapshot shown. * @param int $shown Courses on screen. * @return string Escaped HTML; empty when the empty-state message already speaks. */ private static function filter_status_html( $state, $result, $shown ) { if ( ! empty( $state['unavailable'] ) ) { $text = __( 'Filtering is temporarily unavailable. Please try again later.', 'actikidz-local-activities' ); } elseif ( 0 === $shown ) { return ''; } else { $total = isset( $result['courseCount'] ) && is_numeric( $result['courseCount'] ) ? max( $shown, (int) $result['courseCount'] ) : $shown; if ( $total > $shown ) { /* translators: 1: number of courses shown, 2: number of matching courses. */ $text = sprintf( _n( 'Showing %1$s of %2$s course', 'Showing %1$s of %2$s courses', $total, 'actikidz-local-activities' ), number_format_i18n( $shown ), number_format_i18n( $total ) ); } else { /* translators: %d: number of courses shown after filtering. */ $text = sprintf( _n( '%d course', '%d courses', $shown, 'actikidz-local-activities' ), $shown ); } } return '

' . esc_html( $text ) . '

'; } /** * Hidden fields that keep the page's other query arguments (other modules, page id) when the form is submitted. * * @param array $args Arguments that do not belong to this module. * @return string Escaped HTML. */ private static function hidden_inputs( $args ) { $html = ''; foreach ( $args as $name => $value ) { foreach ( is_array( $value ) ? $value : array( $value ) as $entry ) { $html .= ''; } } return $html; } /** * Address relative to the current page with only the given query arguments. * * @param array $args Arguments to keep. * @return string Such as "?page_id=5", or "?" without arguments. */ private static function query_href( $args ) { return '?' . http_build_query( $args, '', '&', PHP_QUERY_RFC3986 ); } /** * Signed reference to a module for the public results route: configuration, widget and display options. * * The signature (keyed by this site's salt) stops the route from being used for other modules, widget ids or * display options than the ones this site rendered, so it cannot be used to fill the cache with new snapshots. * * @param string $profile_id Configuration id. * @param string $widget_id Widget id. * @param array $options Valid display options. * @return string Token. */ private static function module_token( $profile_id, $widget_id, $options ) { $data = array( 'v' => 2, 'pageView' => isset( self::$request_args['akla_view'] ), 'profile' => $profile_id, 'widget' => $widget_id, 'options' => array_intersect_key( self::options( $options ), array_flip( array( 'limit', 'layout', 'heading_level', 'style', 'block', 'language', 'browse_mode', 'return_path' ) ) ), ); $encoded = rtrim( strtr( base64_encode( wp_json_encode( $data ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Signed transport encoding, never executable code. return 'v2.' . $encoded . '.' . self::token_signature( $encoded ); } /** * Signature of a module reference. * * @param string $payload Reference. * @return string 32 hex digits. */ private static function token_signature( $payload ) { return substr( hash_hmac( 'sha256', 'akla-results|' . $payload, wp_salt( 'auth' ) ), 0, 32 ); } /** * Read a signed module reference. * * @param mixed $token Token from the request. * @return array|null Configuration id, widget id and display options; null when invalid. */ private static function read_token( $token ) { if ( is_string( $token ) && strlen( $token ) <= 1500 && preg_match( '/^v2\.([A-Za-z0-9_-]+)\.([a-f0-9]{32})$/D', $token, $parts ) ) { if ( ! hash_equals( self::token_signature( $parts[1] ), $parts[2] ) ) { return null; } $data = json_decode( base64_decode( strtr( $parts[1], '-_', '+/' ), true ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Signature checked first; JSON only. if ( ! is_array( $data ) || 2 !== ( $data['v'] ?? null ) || ! wp_is_uuid( $data['profile'] ?? '' ) || ! wp_is_uuid( $data['widget'] ?? '' ) || ! is_array( $data['options'] ?? null ) ) { return null; } $profiles = (array) get_option( 'akla_profiles', array() ); $profile = $profiles[ $data['profile'] ] ?? array(); if ( 'page' !== ( $profile['language_mode'] ?? 'fixed' ) ) { unset( $data['options']['language'] ); // A cached frame must follow the current fixed configuration. } if ( empty( $data['pageView'] ) ) { unset( $data['options']['browse_mode'] ); // Only the dedicated directory view pins this override. } return array( 'profile' => $data['profile'], 'widget' => $data['widget'], 'options' => self::options( $data['options'] ), ); } $uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; if ( ! is_string( $token ) || strlen( $token ) > 256 || ! preg_match( '/^((' . $uuid . ')\.(' . $uuid . ')\.(\d{1,2})\.(cards|list|-)\.(\d)\.(compact|-)\.([01]))\.([0-9a-f]{32})$/D', $token, $match ) ) { return null; } if ( ! hash_equals( self::token_signature( $match[1] ), $match[9] ) ) { return null; } $options = array( 'limit' => (int) $match[4], 'layout' => $match[5], 'heading_level' => (int) $match[6], 'style' => $match[7], 'block' => '1' === $match[8], ); return array( 'profile' => $match[2], 'widget' => $match[3], 'options' => self::options( $options ), ); } /** * Public, read-only results route (GET akla/v1/results): the module's content for the visitor filters in the query. * * Answers what a page view with the same address would show, as an HTML fragment for filters.js. The browser only * talks to this site; this site's server reads ActiKidz (cached, limited) and never forwards visitor data. * * @param WP_REST_Request $request Request with akla_module and the page's query arguments. * @return WP_REST_Response|WP_Error Fragment, or 400 for an unknown module reference. */ public static function results( WP_REST_Request $request ) { $module = self::read_token( $request->get_param( 'akla_module' ) ); if ( null === $module ) { return new WP_Error( 'akla_invalid_module', __( 'The request is not supported.', 'actikidz-local-activities' ), array( 'status' => 400 ) ); } $args = $request->get_query_params(); foreach ( array( 'akla_module', 'rest_route', '_locale', '_wpnonce', '_envelope', '_jsonp', '_fields', '_embed', '_method' ) as $internal ) { unset( $args[ $internal ] ); } self::$request_args = self::clean_args( $args ); AKLA_Client::serve_visitor_route( true ); try { $html = self::render( $module['profile'], $module['widget'], 'LIVE', $module['options'] + array( 'fragment' => true ) ); } finally { self::$request_args = null; AKLA_Client::serve_visitor_route( false ); } $response = new WP_REST_Response( array( 'html' => $html ) + self::$response_meta ); $response->header( 'X-Robots-Tag', 'noindex' ); $response->header( 'Cache-Control', 'no-store, max-age=0' ); return $response; } /** * Category names of a course, as named by the service (no local table). * * @param array $item Course facts. * @return string Plain text; empty when the service gave no names. */ private static function category_label( $item ) { $labels = array(); foreach ( is_array( $item['categoryLabels'] ?? null ) ? $item['categoryLabels'] : array() as $label ) { if ( is_string( $label ) && '' !== trim( $label ) && ! in_array( $label, $labels, true ) ) { $labels[] = $label; } } return implode( ' · ', $labels ); } /** * Keep only valid display overrides. * * @param mixed $overrides Overrides (limit, layout, heading_level, style, block). * @return array Valid overrides. */ private static function options( $overrides ) { $overrides = is_array( $overrides ) ? $overrides : array(); $options = array(); if ( is_string( $overrides['return_path'] ?? null ) && strlen( $overrides['return_path'] ) <= 512 && preg_match( '#^/[^/\\\\\r\n]#D', $overrides['return_path'] ) ) { $options['return_path'] = $overrides['return_path']; } foreach ( array( 'language' => AKLA_Admin::LANGUAGES, 'browse_mode' => array( 'directory', 'summary' ), ) as $name => $allowed ) { if ( in_array( $overrides[ $name ] ?? '', $allowed, true ) ) { $options[ $name ] = $overrides[ $name ]; } } if ( is_string( $overrides['instance'] ?? null ) && preg_match( '/^[a-zA-Z0-9_-]{1,64}$/D', $overrides['instance'] ) ) { $options['instance'] = $overrides['instance']; } foreach ( array( 'module', 'prefix' ) as $internal ) { if ( is_string( $overrides[ $internal ] ?? null ) ) { $options[ $internal ] = $overrides[ $internal ]; } } if ( isset( $overrides['limit'] ) && is_numeric( $overrides['limit'] ) && (int) $overrides['limit'] >= 1 && (int) $overrides['limit'] <= 12 ) { $options['limit'] = (int) $overrides['limit']; } if ( isset( $overrides['layout'] ) && in_array( $overrides['layout'], array( 'cards', 'list' ), true ) ) { $options['layout'] = $overrides['layout']; } if ( isset( $overrides['heading_level'] ) && null !== self::heading_level( $overrides['heading_level'], null ) ) { $options['heading_level'] = self::heading_level( $overrides['heading_level'] ); } if ( isset( $overrides['style'] ) && 'compact' === $overrides['style'] ) { $options['style'] = 'compact'; } if ( ! empty( $overrides['block'] ) ) { $options['block'] = true; } if ( ! empty( $overrides['structured_data'] ) ) { $options['structured_data'] = true; } if ( ! empty( $overrides['fragment'] ) ) { $options['fragment'] = true; } return $options; } /** * A heading level from 2 to 6. * * @param mixed $level Raw level. * @param int|null $fallback Value for invalid input. * @return int|null Level. */ private static function heading_level( $level, $fallback = self::HEADING_LEVEL ) { return is_numeric( $level ) && (int) $level >= 2 && (int) $level <= 6 && 0.0 === fmod( (float) $level, 1.0 ) ? (int) $level : $fallback; } /** * A scalar attribute as trimmed text. * * @param mixed $value Raw value. * @return string Text, or an empty string. */ private static function scalar_text( $value ) { return is_scalar( $value ) && ! is_bool( $value ) ? trim( (string) $value ) : ''; } /** * Output attributes, through the block wrapper when rendering a block. * * @param array $attributes Unescaped attribute values by name. * @param bool $block Whether block supports (classes, styles) are merged in. * @return string Escaped attribute list. */ private static function attributes( array $attributes, $block ) { if ( $block && function_exists( 'get_block_wrapper_attributes' ) ) { // Merges supports classes and styles with ours; WordPress escapes every value. return get_block_wrapper_attributes( $attributes ); } $output = array(); foreach ( $attributes as $name => $value ) { $output[] = $name . '="' . esc_attr( $value ) . '"'; } return implode( ' ', $output ); } /** * Whether this request may show administrator-only notices in place of the module. * * Never in REST responses (they may be cached or shown to others) or on administration screens. * * @return bool Whether the current user manages the site on a front-end render. */ private static function admin_notices_allowed() { if ( ! current_user_can( 'manage_options' ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return false; } return ! is_admin() || wp_doing_ajax(); } /** * Administrator message for a connection state. * * @param string $state AKLA_Client::status() state. * @return string Message. */ private static function connection_notice( $state ) { switch ( $state ) { case 'origin_changed': return __( 'This site address changed after it was connected. Reconnect this site in settings.', 'actikidz-local-activities' ); case 'reconnect': return __( 'This connection needs attention. Reconnect in settings.', 'actikidz-local-activities' ); case 'check': return __( 'Course information could not be refreshed for a while. Check the connection in settings.', 'actikidz-local-activities' ); default: return __( 'Connect this site to ActiKidz first.', 'actikidz-local-activities' ); } } /** * One administrator-only notice with a settings link; visitors never receive it. * * @param string $message Message. * @return string Escaped HTML. */ private static function admin_notice( $message ) { return '

' . esc_html__( 'ActiKidz (only administrators see this):', 'actikidz-local-activities' ) . ' ' . esc_html( $message ) . ' ' . esc_html__( 'Open ActiKidz settings', 'actikidz-local-activities' ) . '

'; } /** * A notice where visitors receive no module at all. * * @param string $notice Notice HTML. * @return string Escaped HTML. */ private static function admin_notice_block( $notice ) { return '
' . $notice . '
'; } /** * Select the earliest real occurrence, never a class boundary. * * @param array $sessions Window occurrences. * @return array|null First occurrence. */ private static function nearest_session( $sessions ) { $first = null; foreach ( $sessions as $session ) { if ( null === $first || strtotime( $session['startsAt'] ) < strtotime( $first['startsAt'] ) ) { $first = $session; } } return $first; } /** * Display date boundaries separately from any lesson time. * * @param array $offering Class facts. * @param string $language Component language. * @return string Escaped HTML date range (dates only, in