add_help_tab( array( 'id' => 'akla-help-cache', 'title' => __( 'Caching and CDNs', 'actikidz-local-activities' ), 'content' => '

' . esc_html__( 'The plugin refreshes its server cache every 15 minutes. During temporary errors, checked data can remain for at most one hour and never beyond its expiry. Full-page caches/CDNs can keep HTML longer: set their lifetime to at most one hour and purge them after disconnection or source removal.', 'actikidz-local-activities' ) . '

', ) ); $screen->add_help_tab( array( 'id' => 'akla-help-privacy', 'title' => __( 'Privacy and uninstall', 'actikidz-local-activities' ), 'content' => '

' . esc_html__( 'Course links may include source attribution. Visitors choose analytics consent on ActiKidz.', 'actikidz-local-activities' ) . '

' . esc_html__( 'No ActiKidz scripts, cookies, images, fonts or visitor tracking load on the public page. Deactivation clears plugin caches and preserves settings. Disconnect before uninstalling to revoke the remote installation; uninstall removes local settings.', 'actikidz-local-activities' ) . '

', ) ); $screen->set_help_sidebar( '

' . esc_html__( 'Terms of service', 'actikidz-local-activities' ) . '

' . esc_html__( 'Privacy policy', 'actikidz-local-activities' ) . '

' ); } /** * Suggest privacy policy text for site owners (Tools > Privacy). Must run on admin_init. * * @return void */ public static function privacy_policy() { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $paragraphs = array( __( 'This website uses the ActiKidz Local Activities plugin to show children\'s courses provided by ActiKidz (https://actikidz.com).', 'actikidz-local-activities' ), __( 'After the site administrator connects the plugin, this website\'s server sends the site address, installation credentials and the selected course filters (cities, dates, ages, categories, language and number of courses, plus any days, categories or age range a visitor picks in the module\'s filters) to ActiKidz to retrieve course information. These requests are made by this website\'s server, not by your browser.', 'actikidz-local-activities' ), __( 'The plugin does not send visitor IP addresses, user agents, cookies, email addresses or browsing history to ActiKidz, and it does not load ActiKidz scripts, cookies, images, fonts or tracking on this website.', 'actikidz-local-activities' ), __( 'Course title links lead to the provider\'s official registration page, or to ActiKidz for courses offered on ActiKidz. Source names link to the provider\'s website. When you follow a link, the privacy policy of that website applies. Links to ActiKidz may carry source attribution; ActiKidz asks for analytics consent on its own website.', 'actikidz-local-activities' ), ); $content = ''; foreach ( $paragraphs as $paragraph ) { $content .= '

' . esc_html( $paragraph ) . '

'; } $content .= '

' . esc_html__( 'ActiKidz privacy policy', 'actikidz-local-activities' ) . '

'; wp_add_privacy_policy_content( __( 'ActiKidz Local Activities', 'actikidz-local-activities' ), wp_kses_post( $content ) ); } /** * Return translated editor labels without credentials. * * @return mixed Result or rendered output as documented by the caller. */ public static function editor_labels() { return array( 'title' => __( 'ActiKidz Local Activities', 'actikidz-local-activities' ), 'profile' => __( 'Display configuration', 'actikidz-local-activities' ), 'choose' => __( 'Choose a saved configuration', 'actikidz-local-activities' ), 'settings' => __( 'Open ActiKidz settings', 'actikidz-local-activities' ), 'preview' => __( 'Refresh preview', 'actikidz-local-activities' ), 'loading' => __( 'Loading preview…', 'actikidz-local-activities' ), 'error' => __( 'The preview could not load. Please retry.', 'actikidz-local-activities' ), 'previewNote' => __( 'Editor previews use test attribution. Publish to display live courses.', 'actikidz-local-activities' ), 'empty' => __( 'No saved configurations yet. Connect the service and save a configuration in settings.', 'actikidz-local-activities' ), 'notConnected' => __( 'Connect this site to ActiKidz first.', 'actikidz-local-activities' ), 'refreshConfigurations' => __( 'Refresh configurations', 'actikidz-local-activities' ), 'loadingConfigurations' => __( 'Refreshing configurations…', 'actikidz-local-activities' ), 'configurationsError' => __( 'Configurations could not load. Please retry.', 'actikidz-local-activities' ), 'configurationsRefreshed' => __( 'Configurations refreshed.', 'actikidz-local-activities' ), 'missingConfiguration' => __( 'This configuration is no longer available. Choose another one.', 'actikidz-local-activities' ), ); } /** * Keep only the per-block overrides the editor may send with a preview request. * * @param mixed $raw Overrides from the REST request. * @return array Validated limit, layout, heading_level and style. */ public static function preview_overrides( $raw ) { $raw = is_array( $raw ) ? $raw : array(); $overrides = array(); if ( isset( $raw['limit'] ) && is_numeric( $raw['limit'] ) && (int) $raw['limit'] >= 1 && (int) $raw['limit'] <= 12 ) { $overrides['limit'] = (int) $raw['limit']; } if ( isset( $raw['layout'] ) && in_array( $raw['layout'], array( 'cards', 'list' ), true ) ) { $overrides['layout'] = $raw['layout']; } if ( isset( $raw['headingLevel'] ) && is_numeric( $raw['headingLevel'] ) && (int) $raw['headingLevel'] >= 2 && (int) $raw['headingLevel'] <= 6 ) { $overrides['heading_level'] = (int) $raw['headingLevel']; } if ( isset( $raw['style'] ) && 'compact' === $raw['style'] ) { $overrides['style'] = 'compact'; } return $overrides; } /** * Return saved display names and identifiers for the editor. * * @return mixed Result or rendered output as documented by the caller. */ public static function profile_choices() { $choices = array(); // Same computed names that profile_id_by_slug() resolves, so config="name" shortcodes convert to blocks. foreach ( self::assign_slugs( (array) get_option( 'akla_profiles', array() ) ) as $id => $profile ) { $choices[] = array( 'value' => $id, 'label' => $profile['name'], 'slug' => isset( $profile['slug'] ) ? (string) $profile['slug'] : '', ); } return $choices; } /** * Map the site language to a display language for new configurations. * * @return string One of the supported display languages. */ public static function default_language(): string { $locale = (string) get_locale(); if ( in_array( $locale, array( 'zh_CN', 'zh_SG' ), true ) ) { return 'zh'; } if ( in_array( $locale, array( 'zh_TW', 'zh_HK' ), true ) ) { return 'zh-TW'; } if ( 'fr' === $locale || str_starts_with( $locale, 'fr_' ) ) { return 'fr'; } return 'en'; } /** * Upgrade a stored configuration in memory. * * Configurations saved before separate link settings had one switch (query.linksEnabled) * for both the course title and the source name: both keep that value. * * Platform filters (2026-10-16), same meaning as the ActiKidz search: * - query.categories: platform category codes (any one matches), at most 16, only present when at least one * is selected. A single query.category saved by earlier versions becomes the only entry and the old key * is removed; a null query.category is left as it was, so such configurations keep their request body. * - query.ageRange: one platform age range value. When present, ageMin/ageMax are removed (the platform * refuses both together). Without it, ageMin/ageMax saved by earlier versions are kept unchanged. * - query.weekdays is never stored: weekdays are only a visitor filter. * * @param array $profile Stored configuration. * @return array Configuration with links.course / links.source and a consistent query. */ public static function normalize_profile( array $profile ): array { $query = isset( $profile['query'] ) && is_array( $profile['query'] ) ? $profile['query'] : array(); if ( isset( $profile['links'] ) && is_array( $profile['links'] ) ) { $links = array( 'course' => array_key_exists( 'course', $profile['links'] ) ? (bool) $profile['links']['course'] : true, 'source' => array_key_exists( 'source', $profile['links'] ) ? (bool) $profile['links']['source'] : true, ); } else { $legacy = ! empty( $query['linksEnabled'] ); $links = array( 'course' => $legacy, 'source' => $legacy, ); } $profile['links'] = $links; $query['linksEnabled'] = $links['course']; if ( array_key_exists( 'cityIds', $query ) ) { $city_ids = self::city_ids( $query['cityId'] ?? 0, $query['cityIds'] ); if ( count( $city_ids ) > 1 ) { $query['cityIds'] = $city_ids; } else { unset( $query['cityIds'] ); } } if ( isset( $query['category'] ) && is_string( $query['category'] ) && '' !== $query['category'] ) { $query['categories'] = array_merge( array( $query['category'] ), isset( $query['categories'] ) && is_array( $query['categories'] ) ? $query['categories'] : array() ); unset( $query['category'] ); } if ( array_key_exists( 'categories', $query ) ) { $codes = array_slice( self::category_codes( $query['categories'] ), 0, self::MAX_CATEGORIES ); if ( $codes ) { $query['categories'] = $codes; } else { unset( $query['categories'] ); } } if ( array_key_exists( 'ageRange', $query ) ) { if ( self::valid_age_range( $query['ageRange'] ) ) { unset( $query['ageMin'], $query['ageMax'] ); } else { unset( $query['ageRange'] ); } } unset( $query['weekdays'] ); $profile['query'] = $query; return $profile; } /** * Keep valid, unique platform category codes in their order. * * @param mixed $values List of codes (or one code). * @return string[] Category codes. */ private static function category_codes( $values ): array { $codes = array(); foreach ( is_array( $values ) ? $values : array( $values ) as $value ) { if ( is_string( $value ) && preg_match( self::CATEGORY_PATTERN, $value ) && ! in_array( $value, $codes, true ) ) { $codes[] = $value; } } return $codes; } /** * Whether a value has the shape of a platform age range value. * * @param mixed $value Value. * @return bool Whether it may be an age range value. */ private static function valid_age_range( $value ): bool { return is_string( $value ) && 1 === preg_match( self::AGE_RANGE_PATTERN, $value ); } /** * Describe an age setting saved before platform age ranges (ageMin/ageMax in years). * * @param array $query Normalized stored query. * @return string Readable age setting, or '' when the configuration has none. */ private static function legacy_age_text( array $query ): string { if ( isset( $query['ageRange'] ) ) { return ''; } $minimum = isset( $query['ageMin'] ) && is_numeric( $query['ageMin'] ) ? (int) $query['ageMin'] : null; $maximum = isset( $query['ageMax'] ) && is_numeric( $query['ageMax'] ) ? (int) $query['ageMax'] : null; if ( null !== $minimum && null !== $maximum ) { /* translators: 1: minimum age in years, 2: maximum age in years. */ return sprintf( __( '%1$d–%2$d years', 'actikidz-local-activities' ), $minimum, $maximum ); } if ( null !== $minimum ) { /* translators: %d: minimum age in years. */ return sprintf( __( '%d years and older', 'actikidz-local-activities' ), $minimum ); } if ( null !== $maximum ) { /* translators: %d: maximum age in years. */ return sprintf( __( 'Up to %d years', 'actikidz-local-activities' ), $maximum ); } return ''; } /** * Return the configuration identifier for a readable shortcode name. * * @param string $slug Shortcode name, for example "vancouver-music". * @return string|null Configuration identifier, or null when no configuration uses it. */ public static function profile_id_by_slug( string $slug ): ?string { $slug = self::sanitize_slug( $slug ); if ( '' === $slug ) { return null; } foreach ( self::assign_slugs( (array) get_option( 'akla_profiles', array() ) ) as $id => $profile ) { if ( is_array( $profile ) && ( ( $profile['slug'] ?? '' ) === $slug || in_array( $slug, (array) ( $profile['aliases'] ?? array() ), true ) ) ) { return (string) $id; } } return null; } /** * Store a readable shortcode name for configurations saved before names existed (once). * * @return void */ public static function ensure_slugs() { $profiles = (array) get_option( 'akla_profiles', array() ); $assigned = self::assign_slugs( $profiles ); if ( $assigned !== $profiles ) { update_option( 'akla_profiles', $assigned, false ); } } /** * Reduce text to a shortcode name: lowercase a-z, 0-9 and hyphens, at most 40 characters. * * @param mixed $value Raw name or shortcode name. * @return string Sanitized shortcode name, possibly empty. */ public static function sanitize_slug( $value ): string { $slug = sanitize_title( remove_accents( is_scalar( $value ) ? (string) $value : '' ) ); $slug = (string) preg_replace( '/%[0-9a-f]{2}/i', '', $slug ); $slug = (string) preg_replace( '/[^a-z0-9-]+/', '', strtolower( $slug ) ); $slug = trim( (string) preg_replace( '/-+/', '-', $slug ), '-' ); return trim( substr( $slug, 0, self::SLUG_MAX_LENGTH ), '-' ); } /** * Make a shortcode name unique by appending -2, -3 and so on. * * @param string $base Sanitized base name. * @param array $taken Names already in use. * @return string Unique name of at most 40 characters. */ private static function unique_slug( string $base, array $taken ): string { $base = '' === $base ? 'activities' : $base; $slug = $base; $number = 2; while ( in_array( $slug, $taken, true ) ) { $suffix = '-' . $number; $slug = rtrim( substr( $base, 0, self::SLUG_MAX_LENGTH - strlen( $suffix ) ), '-' ) . $suffix; ++$number; } return $slug; } /** * Give every configuration a valid, unique shortcode name; existing valid names are kept. * * @param array $profiles Stored configurations. * @return array Configurations with a slug each, in the same order. */ private static function assign_slugs( array $profiles ): array { $taken = array_values( (array) get_option( 'akla_retired_slugs', array() ) ); $missing = array(); foreach ( $profiles as $id => $profile ) { if ( ! is_array( $profile ) ) { continue; } $stored = $profile['slug'] ?? ''; if ( is_string( $stored ) && '' !== $stored && self::sanitize_slug( $stored ) === $stored && ! in_array( $stored, $taken, true ) ) { $taken[] = $stored; } else { $missing[] = $id; } } foreach ( $missing as $id ) { foreach ( $profiles as $other ) { $taken = array_merge( $taken, (array) ( $other['aliases'] ?? array() ) ); } $base = self::sanitize_slug( $profiles[ $id ]['slug'] ?? '' ); $base = '' === $base ? self::sanitize_slug( $profiles[ $id ]['name'] ?? '' ) : $base; $profiles[ $id ]['slug'] = self::unique_slug( $base, $taken ); $taken[] = $profiles[ $id ]['slug']; } return $profiles; } /** * Return the main city followed by additional unique cities, at most five. * * @param mixed $primary Main city id. * @param mixed $others All or additional city ids. * @return int[] City ids. */ private static function city_ids( $primary, $others ): array { $ids = array(); foreach ( array_merge( array( $primary ), is_array( $others ) ? $others : array() ) as $value ) { $id = is_scalar( $value ) ? absint( $value ) : 0; if ( $id > 0 && ! in_array( $id, $ids, true ) ) { $ids[] = $id; } } return array_slice( $ids, 0, self::MAX_CITIES ); } /** * Read additional cities from submitted settings. * * @param array $input Unslashed submitted fields. * @param int $primary Main city id (excluded from the result). * @return array List of array( 'cityId' => int, 'label' => string ), possibly longer than allowed. */ private static function extra_cities_input( array $input, int $primary ): array { $ids = isset( $input['extra_city_ids'] ) && is_array( $input['extra_city_ids'] ) ? array_values( $input['extra_city_ids'] ) : array(); $labels = isset( $input['extra_city_labels'] ) && is_array( $input['extra_city_labels'] ) ? array_values( $input['extra_city_labels'] ) : array(); $cities = array(); $seen = array( $primary ); foreach ( array_slice( $ids, 0, 20 ) as $index => $value ) { $id = is_scalar( $value ) ? absint( $value ) : 0; if ( $id <= 0 || in_array( $id, $seen, true ) ) { continue; } $seen[] = $id; $label = isset( $labels[ $index ] ) && is_scalar( $labels[ $index ] ) ? mb_substr( sanitize_text_field( (string) $labels[ $index ] ), 0, 200 ) : ''; $cities[] = array( 'cityId' => $id, 'label' => '' === $label ? (string) $id : $label, ); } return $cities; } /** * Enqueue local settings assets on this plugin page only. * * @param mixed $hook Hook. * * @return mixed Result or rendered output as documented by the caller. */ public static function assets( $hook ) { if ( 'settings_page_actikidz-local-activities' !== $hook ) { return; } wp_enqueue_style( 'akla-admin', plugins_url( 'assets/admin.css', AKLA_FILE ), array(), akla_asset_version( 'assets/admin.css' ) ); wp_enqueue_style( 'akla-public' ); wp_enqueue_script( 'akla-admin', plugins_url( 'assets/admin.js', AKLA_FILE ), array( 'wp-api-fetch', 'akla-preview' ), akla_asset_version( 'assets/admin.js' ), true ); wp_localize_script( 'akla-admin', 'aklaAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'akla_lookup' ), 'connected' => AKLA_Client::connected(), 'maxCities' => self::MAX_CITIES, 'labels' => array_merge( self::editor_labels(), array( 'noCities' => __( 'No matching city.', 'actikidz-local-activities' ), 'loadingCities' => __( 'Searching cities…', 'actikidz-local-activities' ), 'loadingCategories' => __( 'Loading categories…', 'actikidz-local-activities' ), 'categoryError' => __( 'Categories could not load. Please retry.', 'actikidz-local-activities' ), 'copied' => __( 'Copied.', 'actikidz-local-activities' ), 'copyFallback' => __( 'Select and copy the shortcode below.', 'actikidz-local-activities' ), 'cityError' => __( 'Cities could not load. Your input has been kept; retry.', 'actikidz-local-activities' ), 'anyAge' => __( 'Any age', 'actikidz-local-activities' ), /* translators: 1: category name, 2: number of courses. */ 'categoryCount' => __( '%1$s (%2$d)', 'actikidz-local-activities' ), 'citySelected' => __( 'City selected.', 'actikidz-local-activities' ), 'chooseCity' => __( 'Select a city from the matching results before saving.', 'actikidz-local-activities' ), 'saveFirst' => __( 'Save your changes to update the preview.', 'actikidz-local-activities' ), 'deleteConfirm' => __( 'Delete this configuration? Every block or shortcode using it will stop showing courses.', 'actikidz-local-activities' ), 'cityAdded' => __( 'City added.', 'actikidz-local-activities' ), 'cityRemoved' => __( 'City removed.', 'actikidz-local-activities' ), 'cityDuplicate' => __( 'This city is already selected.', 'actikidz-local-activities' ), 'cityLimit' => __( 'You can choose up to 10 cities.', 'actikidz-local-activities' ), /* translators: %s: city, province and country. */ 'removeCity' => __( 'Remove %s', 'actikidz-local-activities' ), 'remove' => __( 'Remove', 'actikidz-local-activities' ), 'checkingCourses' => __( 'Checking available courses…', 'actikidz-local-activities' ), /* translators: %d: number of courses. */ 'courseCount' => __( 'Courses available in the next 90 days: %d', 'actikidz-local-activities' ), /* translators: %d: number of courses. */ 'courseCount30' => __( 'Courses available in the next 30 days: %d', 'actikidz-local-activities' ), 'noCourses' => __( 'No courses are available for this city right now.', 'actikidz-local-activities' ), /* translators: %s: city and province. */ 'addNearby' => __( 'Add %s', 'actikidz-local-activities' ), /* translators: %d: number of courses. */ 'nearbyCourses' => __( 'Courses: %d', 'actikidz-local-activities' ), ) ), ) ); } /** * Require single-site administrative capability before settings access. * * @param mixed $ajax Ajax. * * @return mixed Result or rendered output as documented by the caller. */ private static function authorize( $ajax = false ) { if ( ! current_user_can( 'manage_options' ) || is_multisite() ) { if ( $ajax ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to change these settings.', 'actikidz-local-activities' ) ), 403 ); } wp_die( esc_html__( 'You do not have permission to change these settings.', 'actikidz-local-activities' ), '', array( 'response' => 403 ) ); } } /** * Return the client's connection status (empty while the client does not report one). * * @return array Status as documented by AKLA_Client::status(). */ private static function client_status(): array { $status = method_exists( 'AKLA_Client', 'status' ) ? AKLA_Client::status() : array(); return is_array( $status ) ? $status : array(); } /** * Process a nonce-protected settings mutation. * * @return mixed Result or rendered output as documented by the caller. */ public static function handle() { self::authorize(); check_admin_referer( 'akla_action' ); $operation = isset( $_POST['operation'] ) ? sanitize_key( wp_unslash( $_POST['operation'] ) ) : ''; $result = true; $id = ''; if ( in_array( $operation, array( 'connect', 'disconnect', 'rotate' ), true ) && ! empty( self::client_status()['managed_by_constants'] ) ) { $result = new WP_Error( 'akla_managed', __( 'Credentials are managed in wp-config.php.', 'actikidz-local-activities' ) ); } elseif ( 'connect' === $operation ) { $result = empty( $_POST['consent'] ) ? new WP_Error( 'consent', __( 'Please confirm the connection details first.', 'actikidz-local-activities' ) ) : AKLA_Client::connect(); } elseif ( 'disconnect' === $operation ) { $result = AKLA_Client::disconnect(); } elseif ( 'rotate' === $operation ) { $result = AKLA_Client::rotate(); } elseif ( 'clear_cache' === $operation ) { AKLA_Client::clear_cache(); } elseif ( in_array( $operation, array( 'clone', 'export', 'prepare' ), true ) ) { $id = sanitize_text_field( wp_unslash( $_POST['profile_id'] ?? '' ) ); $profiles = self::assign_slugs( (array) get_option( 'akla_profiles', array() ) ); if ( ! wp_is_uuid( $id ) || ! isset( $profiles[ $id ] ) ) { $result = new WP_Error( 'profile', __( 'Choose a saved configuration', 'actikidz-local-activities' ) ); } elseif ( 'export' === $operation ) { nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="actikidz-settings.json"' ); echo wp_json_encode( array( 'schemaVersion' => AKLA_Profile_Schema::VERSION, 'profile' => AKLA_Profile_Schema::export_profile( $profiles[ $id ] ), ), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ); exit; } elseif ( 'prepare' === $operation ) { $result = AKLA_Client::prepare( $id ); } elseif ( count( $profiles ) >= 20 ) { $result = new WP_Error( 'profile_limit', __( 'You can save up to 20 configurations.', 'actikidz-local-activities' ) ); } else { $copy = $profiles[ $id ]; $taken = (array) get_option( 'akla_retired_slugs', array() ); foreach ( $profiles as $entry ) { $taken = array_merge( $taken, array( $entry['slug'] ), (array) ( $entry['aliases'] ?? array() ) ); } $copy['slug'] = self::unique_slug( $copy['slug'], $taken ); $copy['name'] = $copy['name'] . ' (' . __( 'Copy', 'actikidz-local-activities' ) . ')'; $copy['widget_id'] = wp_generate_uuid4(); $copy['aliases'] = array(); $copy['revision'] = 1; $id = wp_generate_uuid4(); $profiles[ $id ] = $copy; update_option( 'akla_profiles', $profiles, false ); } } elseif ( 'delete' === $operation ) { $id = isset( $_POST['profile_id'] ) ? sanitize_text_field( wp_unslash( $_POST['profile_id'] ) ) : ''; $profiles = (array) get_option( 'akla_profiles', array() ); if ( wp_is_uuid( $id ) && isset( $profiles[ $id ] ) && is_array( $profiles[ $id ] ) ) { $deleted = self::assign_slugs( $profiles )[ $id ]; update_option( 'akla_deleted_profile', AKLA_Profile_Schema::export_profile( $deleted ), false ); update_option( 'akla_retired_slugs', array_values( array_unique( array_merge( (array) get_option( 'akla_retired_slugs', array() ), array( $deleted['slug'] ), (array) ( $deleted['aliases'] ?? array() ) ) ) ), false ); unset( $profiles[ $id ] ); update_option( 'akla_profiles', $profiles, false ); self::record_missing_references( $id, (string) ( $deleted['slug'] ?? '' ), (string) ( $deleted['name'] ?? '' ) ); } AKLA_Client::clear_profile_cache( $id ); $id = ''; } elseif ( 'save' === $operation ) { $result = self::save_profile(); if ( ! is_wp_error( $result ) ) { $id = $result; } else { $draft = get_transient( 'akla_draft_' . get_current_user_id() ); $id = is_array( $draft ) ? $draft['profile_id'] : ''; } } else { $result = new WP_Error( 'action', __( 'The request is not supported.', 'actikidz-local-activities' ) ); } set_transient( 'akla_notice_' . get_current_user_id(), array( 'error' => is_wp_error( $result ), 'text' => is_wp_error( $result ) ? $result->get_error_message() : ( 'save' === $operation ? __( 'Configuration saved.', 'actikidz-local-activities' ) : __( 'Settings updated.', 'actikidz-local-activities' ) ), 'preview_profile' => ! is_wp_error( $result ) && 'save' === $operation ? $id : '', ), 60 ); $saved = ! is_wp_error( $result ) && 'save' === $operation; $onboarding = $saved && ! empty( $_POST['onboarding'] ); $arguments = array( 'page' => self::PAGE_SLUG, 'profile' => $id, ); if ( $onboarding ) { $arguments['setup'] = '3'; } $anchor = $onboarding ? '#akla-setup' : '#akla-publish'; wp_safe_redirect( add_query_arg( $arguments, admin_url( 'options-general.php' ) ) . ( $saved ? $anchor : '' ) ); exit; } /** * Validate and persist a display configuration after authorization. * * Categories and the age range must be values the platform offered: those the coverage check on this * page returned (remembered per user), or those the configuration already has. Anything else is checked * with one coverage request for the submitted cities before saving; names shown in the settings come * from the platform response, never from the submitted form. * * @return mixed Result or rendered output as documented by the caller. */ private static function save_profile() { // Called only after manage_options and nonce checks in handle(). $input = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified by handle(). $profiles = (array) get_option( 'akla_profiles', array() ); $id = sanitize_text_field( $input['profile_id'] ?? '' ); delete_transient( 'akla_draft_' . get_current_user_id() ); if ( '' !== $id && ( ! wp_is_uuid( $id ) || ! isset( $profiles[ $id ] ) ) ) { return new WP_Error( 'profile', __( 'Choose a saved configuration', 'actikidz-local-activities' ) ); } $draft = array(); foreach ( array( 'profile_id', 'name', 'slug', 'city_label', 'province', 'city_id', 'area_id', 'scope_key', 'age_range', 'language', 'window_days', 'limit', 'layout', 'hide_empty', 'brand', 'links_course', 'links_source' ) as $field ) { $draft[ $field ] = isset( $input[ $field ] ) && is_scalar( $input[ $field ] ) ? mb_substr( sanitize_text_field( (string) $input[ $field ] ), 0, 200 ) : ''; } $categories = self::categories_input( $input ); $draft['categories'] = array_slice( self::category_codes( $categories ), 0, self::MAX_CATEGORIES ); $city_id = absint( $input['city_id'] ?? 0 ); $area_id = absint( $input['area_id'] ?? 0 ); $scope_key = sanitize_text_field( $input['scope_key'] ?? '' ); $geography = $area_id ? array( 'areaId' => $area_id ) : ( $scope_key ? array( 'scopeKey' => $scope_key ) : array() ); if ( $geography ) { $city_id = 0; } $extra_cities = $geography ? array() : self::extra_cities_input( $input, $city_id ); $draft['extra_cities'] = array_slice( $extra_cities, 0, self::MAX_CITIES - 1 ); set_transient( 'akla_draft_' . get_current_user_id(), $draft, 10 * MINUTE_IN_SECONDS ); if ( '' === $id && count( $profiles ) >= 20 ) { return new WP_Error( 'profile_limit', __( 'You can save up to 20 configurations.', 'actikidz-local-activities' ) ); } $name = sanitize_text_field( $input['name'] ?? '' ); if ( '' === $name && ! empty( $input['onboarding'] ) ) { // The setup steps only ask for a city: name the first configuration after it. $name = self::city_name( array( 'city_label' => sanitize_text_field( $input['city_label'] ?? '' ) ) ); } if ( ( ! $city_id && ! $geography ) || '' === $name || strlen( $name ) > 200 ) { return new WP_Error( 'profile', __( 'Enter a name and select a city.', 'actikidz-local-activities' ) ); } if ( count( $extra_cities ) > self::MAX_CITIES - 1 ) { return new WP_Error( 'profile_cities', __( 'You can choose up to 10 cities.', 'actikidz-local-activities' ) ); } if ( count( self::category_codes( $categories ) ) !== count( $categories ) || count( $categories ) > self::MAX_CATEGORIES ) { return new WP_Error( 'profile_categories', __( 'Choose at most 16 categories from the list.', 'actikidz-local-activities' ) ); } $stored = '' !== $id && is_array( $profiles[ $id ] ) ? self::normalize_profile( $profiles[ $id ] ) : array(); $old_query = isset( $stored['query'] ) && is_array( $stored['query'] ) ? $stored['query'] : array(); $legacy = '' !== self::legacy_age_text( $old_query ); $age_input = array_key_exists( 'age_range', $input ) && is_scalar( $input['age_range'] ) ? sanitize_text_field( (string) $input['age_range'] ) : null; if ( null === $age_input ) { // A form without the age field (for example a page opened before an update) keeps the saved age setting. $age_input = isset( $old_query['ageRange'] ) ? (string) $old_query['ageRange'] : ( $legacy ? self::LEGACY_AGE : '' ); } if ( self::LEGACY_AGE === $age_input && ! $legacy ) { $age_input = ''; } $age_range = self::LEGACY_AGE === $age_input ? '' : $age_input; if ( '' !== $age_range && ! self::valid_age_range( $age_range ) ) { return new WP_Error( 'profile_age', __( 'Choose an age range from the list.', 'actikidz-local-activities' ) ); } $id = '' === $id ? wp_generate_uuid4() : $id; $language = $input['language'] ?? self::default_language(); $language = in_array( $language, self::LANGUAGES, true ) ? $language : 'en'; $window = 90 === (int) ( $input['window_days'] ?? 30 ) ? 90 : 30; $links = array( 'course' => ! empty( $input['links_course'] ), 'source' => ! empty( $input['links_source'] ), ); $city_list = self::city_ids( $city_id, array_column( $extra_cities, 'cityId' ) ); $choices = self::allowed_choices( $stored, $categories, $age_range, $city_list, $window, $language, $links['course'], $geography ); if ( is_wp_error( $choices ) ) { return $choices; } $taken = (array) get_option( 'akla_retired_slugs', array() ); foreach ( self::assign_slugs( $profiles ) as $other_id => $other ) { if ( (string) $other_id !== $id && is_array( $other ) ) { $taken[] = $other['slug']; $taken = array_merge( $taken, (array) ( $other['aliases'] ?? array() ) ); } } $slug = self::sanitize_slug( $input['slug'] ?? '' ); if ( '' !== $slug && in_array( $slug, $taken, true ) ) { return new WP_Error( 'slug_conflict', __( 'This shortcode name is already in use. Choose another name.', 'actikidz-local-activities' ) ); } $slug = self::unique_slug( '' === $slug ? self::sanitize_slug( $name ) : $slug, $taken ); $query = array( 'cityId' => $city_id, 'windowDays' => $window, ); if ( $geography ) { unset( $query['cityId'] ); $query += $geography; } if ( '' !== $age_range ) { // A platform age range replaces any age setting saved by earlier versions. $query['ageRange'] = $age_range; } elseif ( self::LEGACY_AGE === $age_input ) { // Kept exactly as saved until a platform age range (or any age) is chosen. foreach ( array( 'ageMin', 'ageMax' ) as $key ) { if ( array_key_exists( $key, $old_query ) ) { $query[ $key ] = $old_query[ $key ]; } } } if ( $categories ) { $query['categories'] = $categories; } $query['language'] = $language; $query['limit'] = max( 1, min( 12, absint( $input['limit'] ?? 6 ) ) ); $query['linksEnabled'] = $links['course']; if ( $extra_cities ) { // Single-city configurations keep their previous request body; cityIds only lists several cities. $query['cityIds'] = $city_list; } $category_labels = array(); foreach ( $categories as $code ) { $category_labels[ $code ] = $choices['categories'][ $code ]; } $profiles[ $id ] = array( 'name' => $name, 'slug' => $slug, 'widget_id' => $profiles[ $id ]['widget_id'] ?? wp_generate_uuid4(), 'city_label' => sanitize_text_field( $input['city_label'] ?? '' ), 'province' => sanitize_text_field( $input['province'] ?? '' ), 'extra_cities' => $extra_cities, 'links' => $links, 'query' => $query, 'layout' => 'list' === ( $input['layout'] ?? '' ) ? 'list' : 'cards', 'hide_empty' => ! empty( $input['hide_empty'] ), 'brand' => ! empty( $input['brand'] ), // Platform names recorded for the settings screen only; visitors see the names served with each course. 'category_labels' => $category_labels, 'age_range_label' => '' === $age_range ? '' : $choices['ageRanges'][ $age_range ], 'aliases' => array_values( array_unique( array_filter( array_merge( (array) ( $stored['aliases'] ?? array() ), array( $stored['slug'] ?? '' ) ) ) ) ), 'revision' => (int) ( $stored['revision'] ?? 0 ) + ( $old_query !== $query ? 1 : 0 ), 'language_mode' => 'page' === ( $input['language_mode'] ?? $stored['language_mode'] ?? '' ) ? 'page' : 'fixed', 'delivery' => 'server' === ( $input['delivery'] ?? $stored['delivery'] ?? '' ) ? 'server' : 'dynamic', 'browse_mode' => 'summary' === ( $input['browse_mode'] ?? $stored['browse_mode'] ?? '' ) ? 'summary' : 'directory', 'fallback_url' => esc_url_raw( $input['fallback_url'] ?? $stored['fallback_url'] ?? '', array( 'https' ) ), 'fallback_text' => sanitize_text_field( $input['fallback_text'] ?? $stored['fallback_text'] ?? '' ), ); update_option( 'akla_profiles', $profiles, false ); delete_transient( 'akla_draft_' . get_current_user_id() ); if ( $old_query !== $query || ( $stored['language_mode'] ?? 'fixed' ) !== $profiles[ $id ]['language_mode'] ) { AKLA_Client::clear_profile_cache( $id ); } $args = array( $id, $profiles[ $id ]['widget_id'], 'LIVE', array() ); if ( ! wp_next_scheduled( AKLA_Client::REFRESH_HOOK, $args ) ) { wp_schedule_single_event( time() + 1, AKLA_Client::REFRESH_HOOK, $args ); } return $id; } /** * Read the submitted categories (categories[], or the single category field of earlier forms). * * @param array $input Unslashed submitted fields. * @return string[] Sanitized, non-empty, unique values in submitted order (format not yet checked). */ private static function categories_input( array $input ): array { $raw = isset( $input['categories'] ) && is_array( $input['categories'] ) ? array_values( $input['categories'] ) : array(); if ( ! isset( $input['categories'] ) && isset( $input['category'] ) && is_scalar( $input['category'] ) ) { $raw = array( $input['category'] ); } $values = array(); foreach ( array_slice( $raw, 0, 64 ) as $value ) { $value = is_scalar( $value ) ? sanitize_text_field( (string) $value ) : ''; if ( '' !== $value && ! in_array( $value, $values, true ) ) { $values[] = $value; } } return $values; } /** * Return the platform names of the selected categories and age range, or an error when one was not offered. * * @param array $stored Normalized saved configuration (empty for a new one). * @param string[] $categories Selected category codes (format already checked). * @param string $age_range Selected age range value, or ''. * @param int[] $city_ids Selected cities, main city first. * @param int $window Date window in days. * @param string $language Display language. * @param bool $links Whether course titles link to registration pages. * @return array|WP_Error Array( 'categories' => code => name, 'ageRanges' => value => name ). * @param array $geography Structured area selection, without municipality expansion. */ private static function allowed_choices( array $stored, array $categories, string $age_range, array $city_ids, int $window, string $language, bool $links, array $geography = array() ) { $known = self::recorded_choices(); $query = isset( $stored['query'] ) && is_array( $stored['query'] ) ? $stored['query'] : array(); $names = isset( $stored['category_labels'] ) && is_array( $stored['category_labels'] ) ? $stored['category_labels'] : array(); foreach ( (array) ( $query['categories'] ?? array() ) as $code ) { if ( ! isset( $known['categories'][ $code ] ) ) { $known['categories'][ $code ] = isset( $names[ $code ] ) && is_string( $names[ $code ] ) && '' !== $names[ $code ] ? $names[ $code ] : $code; } } if ( isset( $query['ageRange'] ) && ! isset( $known['ageRanges'][ $query['ageRange'] ] ) ) { $label = isset( $stored['age_range_label'] ) && is_string( $stored['age_range_label'] ) ? $stored['age_range_label'] : ''; $known['ageRanges'][ $query['ageRange'] ] = '' === $label ? $query['ageRange'] : $label; } if ( self::choices_cover( $known, $categories, $age_range ) ) { return $known; } $result = self::coverage_lookup( $city_ids, $window, $language, $links, $geography ); if ( is_wp_error( $result ) ) { return new WP_Error( 'profile_choices_unavailable', __( 'The categories and age ranges could not be checked with ActiKidz. Please retry.', 'actikidz-local-activities' ) ); } $offered = self::coverage_choices( $result ); self::record_choices( $offered ); foreach ( $offered['categories'] as $option ) { $known['categories'][ $option['code'] ] = $option['label']; } foreach ( $offered['ageRanges'] as $option ) { $known['ageRanges'][ $option['value'] ] = $option['label']; } if ( ! self::choices_cover( $known, $categories, $age_range ) ) { return new WP_Error( 'profile_choices', __( 'Choose categories and an age range from the lists provided by ActiKidz.', 'actikidz-local-activities' ) ); } return $known; } /** * Whether every selected category and the selected age range are among the known choices. * * @param array $known Array( 'categories' => code => name, 'ageRanges' => value => name ). * @param string[] $categories Selected category codes. * @param string $age_range Selected age range value, or ''. * @return bool Whether all selections are known. */ private static function choices_cover( array $known, array $categories, string $age_range ): bool { foreach ( $categories as $code ) { if ( ! isset( $known['categories'][ $code ] ) ) { return false; } } return '' === $age_range || isset( $known['ageRanges'][ $age_range ] ); } /** * Read the categories and age ranges the platform offered this user on the settings page (at most one hour old). * * @return array Array( 'categories' => code => name, 'ageRanges' => value => name ). */ private static function recorded_choices(): array { $stored = get_transient( self::CHOICES_TRANSIENT . get_current_user_id() ); $known = array( 'categories' => array(), 'ageRanges' => array(), ); foreach ( array( 'categories', 'ageRanges' ) as $kind ) { $entries = is_array( $stored ) && isset( $stored[ $kind ] ) && is_array( $stored[ $kind ] ) ? $stored[ $kind ] : array(); foreach ( $entries as $value => $label ) { $value = (string) $value; $valid = 'categories' === $kind ? 1 === preg_match( self::CATEGORY_PATTERN, $value ) : self::valid_age_range( $value ); if ( $valid && is_string( $label ) ) { $known[ $kind ][ $value ] = $label; } } } return $known; } /** * Remember the categories and age ranges a coverage response offered, added to earlier ones for one hour. * * Responses can arrive out of order while cities change, so earlier offers are kept rather than replaced. * * @param array $offered Result of coverage_choices(). * @return void */ private static function record_choices( array $offered ) { $known = self::recorded_choices(); foreach ( $offered['categories'] as $option ) { unset( $known['categories'][ $option['code'] ] ); $known['categories'][ $option['code'] ] = $option['label']; } foreach ( $offered['ageRanges'] as $option ) { unset( $known['ageRanges'][ $option['value'] ] ); $known['ageRanges'][ $option['value'] ] = $option['label']; } set_transient( self::CHOICES_TRANSIENT . get_current_user_id(), array( 'categories' => array_slice( $known['categories'], -64, null, true ), 'ageRanges' => array_slice( $known['ageRanges'], -32, null, true ), ), HOUR_IN_SECONDS ); } /** * Request coverage for the selected cities with the date window, language and link setting of the form. * * Categories and the age range are left out, so category counts are counted like the ActiKidz search * facets (without the category filter) and the course count describes the cities. * * @param int[] $city_ids City ids, main city first. * @param int $window Date window in days (30 or 90). * @param string $language Display language. * @param bool $links Whether course titles link to registration pages. * @return mixed Decoded response or WP_Error. * @param array $geography Structured area selection, without municipality expansion. */ private static function coverage_lookup( array $city_ids, int $window, string $language, bool $links, array $geography = array() ) { $query = array( 'cityId' => $city_ids[0] ?? 0, 'windowDays' => $window, 'linksEnabled' => $links ? 'true' : 'false', 'language' => $language, ); if ( count( $city_ids ) > 1 ) { $query['cityIds'] = implode( ',', $city_ids ); } if ( $geography ) { unset( $query['cityId'], $query['cityIds'] ); $query += $geography; } return AKLA_Client::request( '/api/public/distribution/v1/coverage?' . http_build_query( $query ) ); } /** * Keep the category facets and age ranges of a coverage response, in the platform order. * * @param mixed $result Coverage response. * @return array Array( 'categories' => list of code/label/count, 'ageRanges' => list of value/label ). */ private static function coverage_choices( $result ): array { $result = is_array( $result ) ? $result : array(); $facets = isset( $result['facets']['categories'] ) && is_array( $result['facets']['categories'] ) ? $result['facets']['categories'] : array(); $categories = array(); $seen = array(); foreach ( $facets as $facet ) { $code = is_array( $facet ) && isset( $facet['code'] ) && is_string( $facet['code'] ) ? $facet['code'] : ''; if ( ! preg_match( self::CATEGORY_PATTERN, $code ) || isset( $seen[ $code ] ) ) { continue; } $label = isset( $facet['label'] ) && is_scalar( $facet['label'] ) ? mb_substr( sanitize_text_field( (string) $facet['label'] ), 0, 120 ) : ''; $seen[ $code ] = true; $categories[] = array( 'code' => $code, 'label' => '' === $label ? $code : $label, 'count' => isset( $facet['count'] ) && is_numeric( $facet['count'] ) ? max( 0, (int) $facet['count'] ) : 0, ); if ( count( $categories ) >= 64 ) { break; } } $ages = array(); $ranges = isset( $result['ageRanges'] ) && is_array( $result['ageRanges'] ) ? $result['ageRanges'] : array(); foreach ( $ranges as $range ) { $value = is_array( $range ) && isset( $range['value'] ) && is_string( $range['value'] ) ? $range['value'] : ''; if ( ! self::valid_age_range( $value ) || isset( $ages[ $value ] ) ) { continue; } $label = isset( $range['label'] ) && is_scalar( $range['label'] ) ? mb_substr( sanitize_text_field( (string) $range['label'] ), 0, 120 ) : ''; $ages[ $value ] = array( 'value' => $value, 'label' => '' === $label ? $value : $label, ); if ( count( $ages ) >= 32 ) { break; } } return array( 'categories' => $categories, 'ageRanges' => array_values( $ages ), ); } /** * Proxy city suggestions after explicit connection and nonce checks. * * @return mixed Result or rendered output as documented by the caller. */ public static function cities() { self::authorize( true ); check_ajax_referer( 'akla_lookup', 'nonce' ); if ( ! AKLA_Client::connected() ) { wp_send_json_error( array( 'message' => __( 'Connect this site to ActiKidz first.', 'actikidz-local-activities' ) ), 403 ); } $q = isset( $_POST['q'] ) ? sanitize_text_field( wp_unslash( $_POST['q'] ) ) : ''; $result = AKLA_Client::request( '/api/public/distribution/v1/cities?' . http_build_query( array( 'q' => mb_substr( $q, 0, 100 ), 'includeAreas' => 'true', ) ) ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 503 ); } wp_send_json_success( $result ); } /** * Proxy the platform categories (with course counts) and age ranges for the selected cities. * * Uses the form's date window, language and link setting, without its categories and age range: * category counts are counted like the ActiKidz search facets. Names, order and counts are the * platform's; nothing is looked up in the plugin. * * @return mixed Result or rendered output as documented by the caller. */ public static function categories() { self::authorize( true ); check_ajax_referer( 'akla_lookup', 'nonce' ); if ( ! AKLA_Client::connected() ) { wp_send_json_error( array( 'message' => __( 'Connect this site to ActiKidz first.', 'actikidz-local-activities' ) ), 403 ); } $city = isset( $_POST['cityId'] ) ? absint( $_POST['cityId'] ) : 0; $links = isset( $_POST['links'] ) && '1' === $_POST['links']; $others = isset( $_POST['cityIds'] ) ? explode( ',', sanitize_text_field( wp_unslash( $_POST['cityIds'] ) ) ) : array(); $window = isset( $_POST['windowDays'] ) && 30 === absint( $_POST['windowDays'] ) ? 30 : 90; $language = isset( $_POST['language'] ) ? sanitize_text_field( wp_unslash( $_POST['language'] ) ) : self::default_language(); $language = in_array( $language, self::LANGUAGES, true ) ? $language : 'en'; $ids = self::city_ids( $city, $others ); $area_id = isset( $_POST['areaId'] ) ? absint( $_POST['areaId'] ) : 0; $scope_key = isset( $_POST['scopeKey'] ) ? sanitize_text_field( wp_unslash( $_POST['scopeKey'] ) ) : ''; $geography = $area_id ? array( 'areaId' => $area_id ) : ( $scope_key ? array( 'scopeKey' => $scope_key ) : array() ); $result = self::coverage_lookup( $ids, $window, $language, $links, $geography ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 503 ); } $offered = self::coverage_choices( $result ); self::record_choices( $offered ); $count = isset( $result['courseCount'] ) && is_numeric( $result['courseCount'] ) ? max( 0, (int) $result['courseCount'] ) : null; // Notices on other screens mean "nothing in the next 90 days"; a 30-day count only says so when it is positive. if ( ! $geography && null !== $count && ( 90 === $window || $count > 0 ) ) { self::record_coverage( $ids, $count ); } wp_send_json_success( array( 'categories' => $offered['categories'], 'ageRanges' => $offered['ageRanges'], 'courseCount' => $count, 'windowDays' => $window, 'nearbyCities' => self::nearby_cities( $result['nearbyCities'] ?? array(), $ids ), ) ); } /** * Keep at most three valid nearby city suggestions that are not already selected. * * @param mixed $raw nearbyCities from the coverage response. * @param int[] $selected City ids already in the configuration. * @return array List of array( cityId, name, province, courseCount ). */ private static function nearby_cities( $raw, array $selected ): array { $cities = array(); foreach ( is_array( $raw ) ? $raw : array() as $city ) { if ( ! is_array( $city ) || ! isset( $city['cityId'], $city['name'] ) || ! is_scalar( $city['cityId'] ) || ! is_scalar( $city['name'] ) ) { continue; } $id = absint( $city['cityId'] ); $name = mb_substr( sanitize_text_field( (string) $city['name'] ), 0, 120 ); $count = isset( $city['courseCount'] ) && is_numeric( $city['courseCount'] ) ? (int) $city['courseCount'] : 0; if ( $id <= 0 || '' === $name || $count <= 0 || in_array( $id, $selected, true ) ) { continue; } $selected[] = $id; $cities[] = array( 'cityId' => $id, 'name' => $name, 'province' => isset( $city['province'] ) && is_scalar( $city['province'] ) ? mb_substr( sanitize_text_field( (string) $city['province'] ), 0, 20 ) : '', 'courseCount' => $count, ); if ( count( $cities ) >= 3 ) { break; } } return $cities; } /** * Key a set of cities independently of their order. * * @param int[] $city_ids City ids. * @return string Sorted, comma-separated ids. */ private static function coverage_key( array $city_ids ): string { $ids = array_map( 'absint', $city_ids ); sort( $ids ); return 'c' . implode( ',', $ids ); } /** * Remember a course count the settings page received, for notices on other admin screens. * * @param int[] $city_ids City ids of the coverage request. * @param int $count Course count. * @return void */ private static function record_coverage( array $city_ids, int $count ) { $counts = self::coverage_counts(); $key = self::coverage_key( $city_ids ); unset( $counts[ $key ] ); $counts[ $key ] = array( 'count' => max( 0, $count ), 'at' => time(), ); set_transient( self::COVERAGE_TRANSIENT, array_slice( $counts, -50, null, true ), self::COVERAGE_TTL ); } /** * Read recorded course counts that are at most six hours old. * * @return array Map of city-set key to array( count, at ). */ private static function coverage_counts(): array { $counts = get_transient( self::COVERAGE_TRANSIENT ); $now = time(); $fresh = array(); foreach ( is_array( $counts ) ? $counts : array() as $key => $entry ) { if ( is_array( $entry ) && isset( $entry['count'], $entry['at'] ) && (int) $entry['at'] <= $now && $now - (int) $entry['at'] < self::COVERAGE_TTL ) { $fresh[ $key ] = $entry; } } return $fresh; } /** * Return the course count the settings page last saw for these cities, without any request. * * @param int[] $city_ids City ids. * @return int|null Course count, or null when nothing recent was recorded. */ public static function recorded_course_count( array $city_ids ): ?int { $counts = self::coverage_counts(); $key = self::coverage_key( $city_ids ); return isset( $counts[ $key ] ) ? (int) $counts[ $key ]['count'] : null; } /** * Return the cities a stored configuration requests, main city first. * * @param array $profile Stored configuration. * @return int[] City ids. */ private static function profile_city_ids( array $profile ): array { $query = isset( $profile['query'] ) && is_array( $profile['query'] ) ? $profile['query'] : array(); return self::city_ids( $query['cityId'] ?? 0, $query['cityIds'] ?? array() ); } /** * Render a privileged editor preview with PREVIEW attribution. * * @param WP_REST_Request $request Request. * * @return mixed Result or rendered output as documented by the caller. */ public static function preview( WP_REST_Request $request ) { if ( self::preview_rate_limited() ) { return new WP_Error( 'akla_preview_rate_limited', __( 'Too many preview requests. Please wait a minute and try again.', 'actikidz-local-activities' ), array( 'status' => 429 ) ); } if ( ! AKLA_Client::connected() ) { return new WP_Error( 'akla_not_connected', __( 'Connect this site to ActiKidz first.', 'actikidz-local-activities' ), array( 'status' => 409 ) ); } $profile_id = sanitize_text_field( (string) $request->get_param( 'profileId' ) ); $profiles = (array) get_option( 'akla_profiles', array() ); if ( ! wp_is_uuid( $profile_id ) || ! isset( $profiles[ $profile_id ] ) || ! is_array( $profiles[ $profile_id ] ) ) { return new WP_Error( 'akla_profile_missing', __( 'Choose a saved configuration', 'actikidz-local-activities' ), array( 'status' => 404 ) ); } $widget_id = self::preview_widget_id( $profiles[ $profile_id ], sanitize_text_field( (string) $request->get_param( 'widgetId' ) ), absint( $request->get_param( 'postId' ) ) ); $response = new WP_REST_Response( array( 'html' => AKLA_Renderer::render( $profile_id, $widget_id, 'PREVIEW', self::preview_overrides( $request->get_param( 'overrides' ) ) ) ) ); $response->header( 'Cache-Control', 'private, no-store' ); return $response; } /** * Count a preview for the current user; at most 30 per minute. * * @return bool Whether this request is over the limit. */ private static function preview_rate_limited(): bool { $key = 'akla_preview_rate_' . get_current_user_id(); $now = time(); $window = get_transient( $key ); if ( ! is_array( $window ) || ! isset( $window['start'], $window['count'] ) || $now - (int) $window['start'] >= MINUTE_IN_SECONDS || (int) $window['start'] > $now ) { $window = array( 'start' => $now, 'count' => 0, ); } if ( (int) $window['count'] >= self::PREVIEW_RATE_LIMIT ) { return true; } $window['count'] = (int) $window['count'] + 1; set_transient( $key, $window, max( 1, MINUTE_IN_SECONDS - ( $now - (int) $window['start'] ) ) ); return false; } /** * Choose the widget identifier a preview may use. * * Accepted: the configuration's own saved widget, or a block widget that already exists in a * post the current user may edit. Anything else previews with the configuration's own widget, * so arbitrary identifiers never create new cache entries or attribution. * * @param array $profile Stored configuration. * @param string $requested Requested widget identifier. * @param int $post_id Post that contains the block, or 0. * @return string Widget identifier to render with. */ private static function preview_widget_id( array $profile, string $requested, int $post_id ): string { $saved = isset( $profile['widget_id'] ) && is_string( $profile['widget_id'] ) ? $profile['widget_id'] : ''; if ( ! wp_is_uuid( $requested ) || $requested === $saved ) { return $saved; } if ( $post_id > 0 && current_user_can( 'edit_post', $post_id ) ) { $post = get_post( $post_id ); if ( $post && self::blocks_contain_widget( parse_blocks( (string) $post->post_content ), $requested ) ) { return $requested; } } return $saved; } /** * Find an ActiKidz block with the given widget identifier, including nested blocks. * * @param array $blocks Parsed blocks. * @param string $widget_id Widget identifier. * @return bool Whether the block exists. */ private static function blocks_contain_widget( array $blocks, string $widget_id ): bool { foreach ( $blocks as $block ) { $attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); if ( self::BLOCK_NAME === ( $block['blockName'] ?? '' ) && isset( $attributes['widgetId'] ) && $attributes['widgetId'] === $widget_id ) { return true; } if ( ! empty( $block['innerBlocks'] ) && self::blocks_contain_widget( $block['innerBlocks'], $widget_id ) ) { return true; } } return false; } /** * Allow content editors to use saved configurations without managing connections. * * @return bool Whether this user may use the block editor integration. */ public static function can_use_editor() { return ! is_multisite() && ( current_user_can( 'manage_options' ) || current_user_can( 'edit_posts' ) || current_user_can( 'edit_pages' ) ); } /** * Limit editor requests to content editors. * * Only capabilities are checked here. WordPress core verifies the REST nonce for cookie * authentication (and treats a cookie request without one as logged out), while * Application Password requests carry no nonce and must not be refused. * * @return bool Whether the current request may use saved configurations. */ public static function editor_state_permission() { return self::can_use_editor(); } /** * Return editor choices without exposing installation secrets or configuration filters. * * @return WP_REST_Response Current editor choices. */ public static function editor_state() { $response = new WP_REST_Response( array( 'connected' => AKLA_Client::connected(), 'profiles' => self::profile_choices(), ) ); $response->header( 'Cache-Control', 'private, no-store' ); return $response; } /** * Output a local nonce-protected action form. * * @param mixed $operation Operation. * @param mixed $label Label. * * @return mixed Result or rendered output as documented by the caller. */ private static function action_form( $operation, $label ) { echo '
'; wp_nonce_field( 'akla_action' ); echo ''; submit_button( $label, 'secondary', 'submit', false ); echo '
'; } /** * Output connection maintenance actions; credential actions are hidden when wp-config.php manages them. * * @param array $status Client status. * @param array $connection Stored connection. * @param bool $connected Whether the site is connected. * @return void */ private static function maintenance_actions( array $status, array $connection, bool $connected ) { $managed = ! empty( $status['managed_by_constants'] ); if ( $managed ) { echo '

' . esc_html__( 'Credentials are managed in wp-config.php.', 'actikidz-local-activities' ) . '

'; } if ( $connected && ! $managed ) { self::action_form( 'rotate', __( 'Refresh connection credentials', 'actikidz-local-activities' ) ); } if ( $connected ) { self::action_form( 'clear_cache', __( 'Clear plugin cache', 'actikidz-local-activities' ) ); } if ( ! empty( $connection['uuid'] ) && ! $managed ) { self::action_form( 'disconnect', __( 'Disconnect service', 'actikidz-local-activities' ) ); } } /** * Output an escaped labelled form input. * * @param string $key Element id. * @param string $name Field name. * @param string $label Visible label. * @param mixed $value Value. * @param string $type Input type. * @param array $attributes Extra attributes; true outputs a boolean attribute, false/null omits it. * @return void */ private static function input( $key, $name, $label, $value, $type = 'text', $attributes = array() ) { echo '

$attribute_value ) { if ( true === $attribute_value ) { echo ' ' . esc_attr( $attribute ); } elseif ( false !== $attribute_value && null !== $attribute_value ) { echo ' ' . esc_attr( $attribute ) . '="' . esc_attr( (string) $attribute_value ) . '"'; } } echo '>

'; } /** * Output an escaped labelled select. * * @param mixed $key Key. * @param mixed $name Name. * @param mixed $label Label. * @param mixed $value Value. * @param mixed $choices Choices. * * @return mixed Result or rendered output as documented by the caller. */ private static function select( $key, $name, $label, $value, $choices ) { echo '

'; } /** * Output the platform age range select, with the earlier age setting kept as an option when there is one. * * @param string $value Selected value: '', an age range value, or the legacy marker. * @param string $legacy Readable age setting saved before platform age ranges, or ''. * @param array $profile Configuration shown in the form. * @return void */ private static function age_field( string $value, string $legacy, array $profile ) { $choices = array( '' => __( 'Any age', 'actikidz-local-activities' ) ); if ( '' !== $legacy ) { /* translators: %s: age setting, for example "5–9 years". */ $choices[ self::LEGACY_AGE ] = sprintf( __( 'Keep the earlier setting: %s', 'actikidz-local-activities' ), $legacy ); } foreach ( self::recorded_choices()['ageRanges'] as $range => $label ) { $choices[ (string) $range ] = $label; } $query = isset( $profile['query'] ) && is_array( $profile['query'] ) ? $profile['query'] : array(); if ( isset( $query['ageRange'] ) && ! isset( $choices[ (string) $query['ageRange'] ] ) ) { $label = isset( $profile['age_range_label'] ) && is_string( $profile['age_range_label'] ) ? $profile['age_range_label'] : ''; $choices[ (string) $query['ageRange'] ] = '' === $label ? (string) $query['ageRange'] : $label; } if ( ! isset( $choices[ $value ] ) && self::valid_age_range( $value ) ) { $choices[ $value ] = $value; } $described = '' === $legacy ? 'akla-age-help' : 'akla-age-help akla-age-legacy'; echo '

'; echo '

' . esc_html__( 'The same age ranges as the ActiKidz website.', 'actikidz-local-activities' ) . '

'; if ( '' !== $legacy ) { /* translators: %s: age setting, for example "5–9 years". */ echo '

' . esc_html( sprintf( __( 'Earlier age setting: %s. Choose an age range to replace it.', 'actikidz-local-activities' ), $legacy ) ) . '

'; } } /** * Output the platform category checkboxes; the selected ones are shown with the names saved with them. * * The full list with course counts is filled in from the coverage check once a city is selected. * * @param string[] $chosen Selected category codes. * @param array $profile Configuration shown in the form. * @return void */ private static function category_field( array $chosen, array $profile ) { $saved = isset( $profile['category_labels'] ) && is_array( $profile['category_labels'] ) ? $profile['category_labels'] : array(); $recorded = self::recorded_choices()['categories']; echo '
'; echo '' . esc_html__( 'Categories (optional)', 'actikidz-local-activities' ) . ''; echo '

' . esc_html__( 'Leave all unchecked to show every category. Names and course counts come from ActiKidz for the selected cities and date window.', 'actikidz-local-activities' ) . '

'; echo '
'; } /** * Output one selected additional city with its hidden form values. * * @param array $city City with cityId and label. * @return void */ private static function extra_city_item( array $city ) { /* translators: %s: city, province and country. */ $remove = sprintf( __( 'Remove %s', 'actikidz-local-activities' ), $city['label'] ); echo '
  • ' . esc_html( $city['label'] ) . '' . '' . '' . '
  • '; } /** * Return the settings form values for a new configuration. * * @return array Configuration defaults (links on, brand credit off, site language). */ private static function new_profile(): array { return array( 'name' => '', 'slug' => '', 'city_label' => '', 'province' => '', 'extra_cities' => array(), 'layout' => 'cards', 'hide_empty' => false, 'brand' => false, 'links' => array( 'course' => true, 'source' => true, ), 'query' => array( 'cityId' => '', 'windowDays' => 30, 'language' => self::default_language(), 'limit' => 6, 'linksEnabled' => true, ), 'category_labels' => array(), 'age_range_label' => '', ); } /** * Return the settings screen address. * * @param array $arguments Extra query arguments. * @return string Unescaped admin URL. */ public static function settings_url( array $arguments = array() ): string { return add_query_arg( array_merge( array( 'page' => self::PAGE_SLUG ), $arguments ), admin_url( 'options-general.php' ) ); } /** * Put a Settings link first in this plugin's row on the Plugins screen (administrators only). * * @param array $links Existing action links. * @return array Action links. */ public static function action_links( array $links ): array { if ( is_multisite() || ! current_user_can( 'manage_options' ) ) { return $links; } array_unshift( $links, '' . esc_html__( 'Settings', 'actikidz-local-activities' ) . '' ); return $links; } /** * Open the settings screen once after a single, interactive activation. * * Bulk activation, network screens, AJAX, cron, WP-CLI and users who cannot manage options only clear the flag. * * @return void */ public static function maybe_redirect_after_activation() { $pending = get_option( self::ACTIVATION_OPTION, false ); if ( false === $pending ) { return; } delete_option( self::ACTIVATION_OPTION ); if ( self::request_has( 'activate-multi' ) || is_multisite() || is_network_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) || ! current_user_can( 'manage_options' ) ) { return; } // A timestamp flag older than ten minutes belongs to an activation that never reached an admin screen. if ( is_numeric( $pending ) && (int) $pending > 1 && time() - (int) $pending > 10 * MINUTE_IN_SECONDS ) { return; } wp_safe_redirect( self::settings_url() ); exit; } /** * Whether the current admin URL carries a query argument (read-only display flags set by core or this page). * * @param string $key Query argument. * @return bool Whether it is present. */ private static function request_has( string $key ): bool { parse_str( (string) wp_parse_url( add_query_arg( array() ), PHP_URL_QUERY ), $query ); return array_key_exists( $key, $query ); } /** * Return the current setup step: 1 connect, 2 choose a city, 3 add to a page. * * @return int Setup step. */ public static function setup_step(): int { if ( ! AKLA_Client::connected() ) { return 1; } return array_filter( (array) get_option( 'akla_profiles', array() ), 'is_array' ) ? 3 : 2; } /** * Create a draft page that contains the ActiKidz block for a saved configuration, then open it. * * @return void */ public static function create_page() { if ( is_multisite() || ! current_user_can( 'edit_pages' ) ) { wp_die( esc_html__( 'You do not have permission to create pages.', 'actikidz-local-activities' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'akla_create_page' ); $id = isset( $_POST['profile_id'] ) ? sanitize_text_field( wp_unslash( $_POST['profile_id'] ) ) : ''; $profiles = (array) get_option( 'akla_profiles', array() ); if ( ! wp_is_uuid( $id ) || ! isset( $profiles[ $id ] ) || ! is_array( $profiles[ $id ] ) ) { wp_die( esc_html__( 'Choose a saved configuration', 'actikidz-local-activities' ), '', array( 'response' => 400 ) ); } $post_id = wp_insert_post( wp_slash( array( 'post_type' => 'page', 'post_status' => 'draft', /* translators: %s: city name. */ 'post_title' => sprintf( __( 'Kids activities in %s', 'actikidz-local-activities' ), self::city_name( $profiles[ $id ] ) ), 'post_content' => self::block_markup( $id, wp_generate_uuid4() ), ) ), true ); if ( is_wp_error( $post_id ) || ! $post_id ) { wp_die( esc_html__( 'The page could not be created. Please retry.', 'actikidz-local-activities' ), '', array( 'response' => 500 ) ); } wp_safe_redirect( (string) get_edit_post_link( $post_id, 'raw' ) ); exit; } /** * Serialize the ActiKidz block for a configuration. * * @param string $profile_id Configuration identifier. * @param string $widget_id Block widget identifier. * @return string Block comment markup. */ public static function block_markup( string $profile_id, string $widget_id ): string { return serialize_block( array( 'blockName' => self::BLOCK_NAME, 'attrs' => array( 'profileId' => $profile_id, 'widgetId' => $widget_id, ), 'innerBlocks' => array(), 'innerHTML' => '', 'innerContent' => array(), ) ); } /** * Return the main city name of a configuration, or its name when the city label is empty. * * @param array $profile Stored configuration. * @return string City name. */ private static function city_name( array $profile ): string { $parts = explode( ',', (string) ( $profile['city_label'] ?? '' ) ); $city = trim( $parts[0] ); return '' !== $city ? $city : trim( (string) ( $profile['name'] ?? '' ) ); } /** * Post types whose content can hold the block or shortcode. * * @return string[] Post type names. */ private static function reference_post_types(): array { $types = array_merge( array_values( get_post_types( array( 'show_ui' => true ) ) ), array( 'page', 'post', 'wp_block', 'wp_template', 'wp_template_part' ) ); return array_values( array_diff( array_unique( $types ), array( 'attachment' ) ) ); } /** * When a configuration is deleted, remember (at most 20) posts whose block or shortcode still uses it. * * This targeted search runs once per deletion; other admin screens only read the stored result. * * @param string $profile_id Deleted configuration identifier. * @param string $slug Its shortcode name. * @param string $name Its name. * @return void */ private static function record_missing_references( string $profile_id, string $slug, string $name ) { $phrases = array( $profile_id ); if ( '' !== $slug ) { $phrases[] = 'config="' . $slug . '"'; } $candidates = array(); foreach ( $phrases as $phrase ) { $candidates = array_merge( $candidates, get_posts( array( 's' => $phrase, 'sentence' => true, 'search_columns' => array( 'post_content' ), 'post_type' => self::reference_post_types(), 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ), 'numberposts' => 20, 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ) ); } $items = get_option( self::REFERENCES_OPTION, array() ); $items = is_array( $items ) ? $items : array(); foreach ( $candidates as $post ) { if ( $post instanceof WP_Post && self::content_references_profile( (string) $post->post_content, $profile_id, $slug ) ) { $items[] = array( 'post' => (int) $post->ID, 'profile' => $profile_id, 'slug' => $slug, 'name' => $name, ); } } $unique = array(); foreach ( $items as $item ) { if ( is_array( $item ) && isset( $item['post'], $item['profile'] ) ) { $unique[ $item['post'] . '|' . $item['profile'] ] = $item; } } update_option( self::REFERENCES_OPTION, array_slice( array_values( $unique ), -20 ), false ); } /** * Return posts that still use a deleted configuration; entries that were fixed are dropped. * * Reads only the posts recorded at deletion (one query, none when nothing is recorded). * * @return array List of array( post, profile, slug, name ). */ public static function missing_references(): array { $items = get_option( self::REFERENCES_OPTION, array() ); if ( ! is_array( $items ) || ! $items ) { return array(); } $posts = array(); $ids = array_filter( array_map( 'absint', array_column( $items, 'post' ) ) ); if ( $ids ) { foreach ( get_posts( array( 'include' => $ids, 'post_type' => self::reference_post_types(), 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ), 'numberposts' => 20, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ) as $post ) { $posts[ $post->ID ] = $post; } } $profiles = (array) get_option( 'akla_profiles', array() ); $valid = array(); foreach ( $items as $item ) { if ( ! is_array( $item ) || ! isset( $item['post'], $item['profile'] ) || ! isset( $posts[ (int) $item['post'] ] ) || isset( $profiles[ $item['profile'] ] ) ) { continue; } // A shortcode name that a newer configuration took over is no longer broken. $slug = (string) ( $item['slug'] ?? '' ); $slug = '' !== $slug && null === self::profile_id_by_slug( $slug ) ? $slug : ''; if ( self::content_references_profile( (string) $posts[ (int) $item['post'] ]->post_content, (string) $item['profile'], $slug ) ) { $valid[] = $item; } } if ( $valid !== $items ) { if ( $valid ) { update_option( self::REFERENCES_OPTION, $valid, false ); } else { delete_option( self::REFERENCES_OPTION ); } } return $valid; } /** * Whether content holds an ActiKidz block or shortcode for a configuration. * * @param string $content Post content. * @param string $profile_id Configuration identifier. * @param string $slug Shortcode name to match, or empty to match identifiers only. * @return bool Whether the configuration is used. */ private static function content_references_profile( string $content, string $profile_id, string $slug ): bool { if ( self::blocks_reference_profile( parse_blocks( $content ), $profile_id ) ) { return true; } if ( ! preg_match_all( '/' . get_shortcode_regex( array( 'actikidz_activities' ) ) . '/', $content, $matches, PREG_SET_ORDER ) ) { return false; } foreach ( $matches as $match ) { $attributes = shortcode_parse_atts( $match[3] ); if ( ! is_array( $attributes ) ) { continue; } if ( ( $attributes['id'] ?? '' ) === $profile_id || ( '' !== $slug && isset( $attributes['config'] ) && self::sanitize_slug( $attributes['config'] ) === $slug ) ) { return true; } } return false; } /** * Find an ActiKidz block that uses a configuration, including nested blocks. * * @param array $blocks Parsed blocks. * @param string $profile_id Configuration identifier. * @return bool Whether such a block exists. */ private static function blocks_reference_profile( array $blocks, string $profile_id ): bool { foreach ( $blocks as $block ) { $attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); if ( self::BLOCK_NAME === ( $block['blockName'] ?? '' ) && ( $attributes['profileId'] ?? '' ) === $profile_id ) { return true; } if ( ! empty( $block['innerBlocks'] ) && self::blocks_reference_profile( $block['innerBlocks'], $profile_id ) ) { return true; } } return false; } /** * Build the administrator notices for the current state, without any network request. * * @return array Map of notice key to array( type, signature, message, link, link_text ). */ public static function current_notices(): array { $notices = array(); $status = self::client_status(); $state = (string) ( $status['state'] ?? ( AKLA_Client::connected() ? 'connected' : 'not_connected' ) ); $profiles = array_filter( (array) get_option( 'akla_profiles', array() ), 'is_array' ); $settings = self::settings_url(); $open = __( 'Open ActiKidz settings', 'actikidz-local-activities' ); $messages = array( 'reconnect' => array( 'error', __( 'ActiKidz Local Activities must be connected again. Courses are not shown until you reconnect.', 'actikidz-local-activities' ) ), 'origin_changed' => array( 'error', __( 'This site\'s address changed after ActiKidz Local Activities was connected. Connect again to show courses.', 'actikidz-local-activities' ) ), 'check' => array( 'warning', __( 'ActiKidz Local Activities has not been able to refresh courses for a while. Visitors may see older courses or none. Check the connection.', 'actikidz-local-activities' ) ), ); if ( 'not_connected' === $state && $profiles ) { $messages['not_connected'] = array( 'warning', __( 'ActiKidz Local Activities is not connected. Saved configurations show no courses until you connect.', 'actikidz-local-activities' ) ); } if ( isset( $messages[ $state ] ) ) { $notices['connection'] = array( 'type' => $messages[ $state ][0], 'signature' => $state, 'message' => $messages[ $state ][1], 'link' => $settings, 'link_text' => $open, ); } if ( in_array( $state, array( 'connected', 'check' ), true ) ) { $empty = array(); foreach ( $profiles as $id => $profile ) { if ( 0 === self::recorded_course_count( self::profile_city_ids( $profile ) ) ) { $empty[ (string) $id ] = (string) ( $profile['name'] ?? '' ); } } if ( $empty ) { $ids = array_keys( $empty ); sort( $ids ); $notices['empty_city'] = array( 'type' => 'warning', 'signature' => 'empty:' . md5( implode( ',', $ids ) ), /* translators: %s: comma-separated configuration names. */ 'message' => sprintf( __( 'These ActiKidz configurations currently have no courses to show: %s. Add a nearby city or choose another city.', 'actikidz-local-activities' ), implode( ', ', $empty ) ), 'link' => self::settings_url( array( 'profile' => array_key_first( $empty ) ) ), 'link_text' => $open, ); } } $missing = self::missing_references(); if ( $missing ) { $titles = array(); foreach ( $missing as $item ) { $title = get_the_title( (int) $item['post'] ); $titles[] = '' !== $title ? $title : __( '(no title)', 'actikidz-local-activities' ); } $edit = get_edit_post_link( (int) $missing[0]['post'], 'raw' ); $notices['missing_profile'] = array( 'type' => 'warning', 'signature' => 'missing:' . md5( (string) wp_json_encode( $missing ) ), /* translators: %s: comma-separated page titles. */ 'message' => sprintf( __( 'A deleted ActiKidz configuration is still used on: %s. Those places show no courses; choose another configuration there.', 'actikidz-local-activities' ), implode( ', ', array_unique( $titles ) ) ), 'link' => $edit ? $edit : $settings, 'link_text' => $edit ? __( 'Edit page', 'actikidz-local-activities' ) : $open, ); } return $notices; } /** * Return the notice states the current user dismissed. * * @return array Map of notice key to dismissed signature. */ private static function dismissed_notices(): array { $dismissed = get_user_meta( get_current_user_id(), self::DISMISSED_META, true ); return is_array( $dismissed ) ? array_intersect_key( $dismissed, array_flip( self::NOTICE_KEYS ) ) : array(); } /** * Show plugin problems on admin screens to administrators; never on this plugin's settings page. * * @return void */ public static function admin_notices() { if ( is_multisite() || ! current_user_can( 'manage_options' ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( $screen && 'settings_page_' . self::PAGE_SLUG === $screen->id ) { return; } $dismissed = self::dismissed_notices(); $shown = false; foreach ( self::current_notices() as $key => $notice ) { if ( ( $dismissed[ $key ] ?? '' ) === $notice['signature'] ) { continue; } printf( '

    %4$s %5$s %7$s

    ', esc_attr( $notice['type'] ), esc_attr( $key ), esc_attr( wp_create_nonce( 'akla_dismiss_notice' ) ), esc_html__( 'ActiKidz:', 'actikidz-local-activities' ), esc_html( $notice['message'] ), esc_url( $notice['link'] ), esc_html( $notice['link_text'] ) ); $shown = true; } if ( $shown ) { // Remember a dismissal made with the core close button; without JavaScript the notice simply stays. wp_add_inline_script( 'common', 'document.addEventListener("click",function(e){var b=e.target&&e.target.closest?e.target.closest(".notice-dismiss"):null,n=b?b.closest("[data-akla-notice]"):null;if(!n||!window.ajaxurl||!window.fetch){return;}window.fetch(window.ajaxurl,{method:"POST",credentials:"same-origin",body:new URLSearchParams({action:"akla_dismiss_notice",notice:n.getAttribute("data-akla-notice"),nonce:n.getAttribute("data-akla-nonce")})});});' ); } } /** * AJAX: remember that the current administrator dismissed a notice in its current state. * * @return void */ public static function dismiss_notice() { self::authorize( true ); check_ajax_referer( 'akla_dismiss_notice', 'nonce' ); $key = isset( $_POST['notice'] ) ? sanitize_key( wp_unslash( $_POST['notice'] ) ) : ''; if ( ! self::dismiss_notice_for_current_user( $key ) ) { wp_send_json_error( null, 400 ); } wp_send_json_success(); } /** * Store the current state of a notice as dismissed; a later state change shows it again. * * @param string $key Notice key. * @return bool Whether a current notice was dismissed. */ public static function dismiss_notice_for_current_user( string $key ): bool { if ( ! in_array( $key, self::NOTICE_KEYS, true ) || ! current_user_can( 'manage_options' ) ) { return false; } $notices = self::current_notices(); if ( ! isset( $notices[ $key ] ) ) { return false; } $dismissed = self::dismissed_notices(); $dismissed[ $key ] = $notices[ $key ]['signature']; update_user_meta( get_current_user_id(), self::DISMISSED_META, $dismissed ); return true; } /** * Output the connection explanation, warnings and the consent form. * * @param array $connection Stored connection. * @param bool $connected Whether the site is connected. * @param bool $managed Whether wp-config.php manages the credentials. * @param string $coverage_url Public coverage page. * @return void */ private static function connection_body( array $connection, bool $connected, bool $managed, string $coverage_url ) { echo '

    ' . esc_html__( 'Coverage varies by city and available sources. Check real courses before connecting; no account is required.', 'actikidz-local-activities' ) . ' ' . esc_html__( 'Check city coverage', 'actikidz-local-activities' ) . '

    '; if ( $connected ) { echo '

    ' . esc_html( AKLA_Client::origin() ) . '

    '; } if ( $managed ) { echo '

    ' . esc_html__( 'Credentials are managed in wp-config.php.', 'actikidz-local-activities' ) . '

    '; } if ( ! empty( $connection['origin'] ) && AKLA_Client::origin() !== $connection['origin'] ) { echo '

    ' . esc_html__( 'This site address has changed. Connect again to create a separate installation.', 'actikidz-local-activities' ) . '

    '; } if ( 'disconnect_pending' === ( $connection['status'] ?? '' ) ) { echo '

    ' . esc_html__( 'Local display is disabled. Remote disconnection is pending; retry disconnect when the service is available.', 'actikidz-local-activities' ) . '

    '; } if ( ! empty( $connection['rotation'] ) ) { echo '

    ' . esc_html__( 'Credential refresh is pending. Retry refresh to recover the same operation.', 'actikidz-local-activities' ) . '

    '; } if ( $connected || $managed ) { return; } echo '
    '; wp_nonce_field( 'akla_action' ); echo ''; echo '

    '; echo '

    ' . esc_html__( 'Terms of service', 'actikidz-local-activities' ) . ' · ' . esc_html__( 'Privacy policy', 'actikidz-local-activities' ) . '

    '; submit_button( __( 'Connect free service', 'actikidz-local-activities' ), 'primary', 'submit', false ); echo '
    '; } /** * Output the three setup steps; collapsed once a configuration exists unless reopened. * * @param int $step Current step (1–3). * @param bool $open Whether the steps are expanded. * @param array $connection Stored connection. * @param bool $managed Whether wp-config.php manages the credentials. * @param string $coverage_url Public coverage page. * @param string $profile_id Configuration used by step 3. * @param array $profile That configuration (with slug). * @param bool $focus Whether scripts should move focus to the steps. * @return void */ private static function setup_guide( int $step, bool $open, array $connection, bool $managed, string $coverage_url, string $profile_id, array $profile, bool $focus ) { $labels = array( 1 => __( 'Connect', 'actikidz-local-activities' ), 2 => __( 'Choose a city', 'actikidz-local-activities' ), 3 => __( 'Add to a page', 'actikidz-local-activities' ), ); echo '
    '; echo '' . ( $step < 3 ? esc_html__( 'Setup steps', 'actikidz-local-activities' ) : esc_html__( 'Show setup steps', 'actikidz-local-activities' ) ) . ''; echo '
      '; foreach ( $labels as $number => $label ) { $class = $number < $step ? 'is-done' : ( $number === $step ? 'is-current' : 'is-next' ); echo '
    1. '; if ( $number < $step ) { echo '' . esc_html__( 'Completed:', 'actikidz-local-activities' ) . ' '; } echo ' ' . esc_html( $label ) . '
    2. '; } echo '
    '; if ( 1 === $step ) { echo '

    ' . esc_html__( '1. Check your city and connect', 'actikidz-local-activities' ) . '

    '; self::connection_body( $connection, false, $managed, $coverage_url ); } elseif ( 2 === $step ) { echo '

    ' . esc_html__( '2. Choose a city', 'actikidz-local-activities' ) . '

    '; echo '

    ' . esc_html__( 'Select the city whose children\'s courses you want to show. The number of available courses appears as soon as you select it; every other option can be changed later.', 'actikidz-local-activities' ) . ' ' . esc_html__( 'Go to the city field', 'actikidz-local-activities' ) . '

    '; } else { $slug = (string) ( $profile['slug'] ?? '' ); echo '

    ' . esc_html__( '3. Add to a page', 'actikidz-local-activities' ) . '

    '; /* translators: %s: configuration name. */ echo '

    ' . esc_html( sprintf( __( '“%s” is ready. Create a draft page that already contains the ActiKidz block, or copy the shortcode for a page builder or classic editor.', 'actikidz-local-activities' ), (string) ( $profile['name'] ?? '' ) ) ) . '

    '; if ( current_user_can( 'edit_pages' ) ) { echo '
    '; wp_nonce_field( 'akla_create_page' ); echo ''; submit_button( __( 'Create a draft page with this block', 'actikidz-local-activities' ), 'primary', 'submit', false ); echo '
    '; } echo ''; echo '
    '; echo '

    '; } echo '
    '; } /** * Display connection, configuration and publishing controls. * * @return mixed Result or rendered output as documented by the caller. */ public static function page() { self::authorize(); $profiles = array(); foreach ( self::assign_slugs( (array) get_option( 'akla_profiles', array() ) ) as $key => $stored ) { if ( is_array( $stored ) ) { $profiles[ $key ] = self::normalize_profile( $stored ); } } $id = isset( $_GET['profile'] ) ? sanitize_text_field( wp_unslash( $_GET['profile'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only selection. $profile = self::new_profile(); if ( isset( $profiles[ $id ] ) ) { $defaults = $profile['query']; $profile = array_replace( $profile, $profiles[ $id ] ); $profile['query'] = array_replace( $defaults, $profile['query'] ); } else { $id = ''; } $slug = $profile['slug']; $legacy_age = self::legacy_age_text( $profile['query'] ); $age_value = isset( $profile['query']['ageRange'] ) ? (string) $profile['query']['ageRange'] : ( '' !== $legacy_age ? self::LEGACY_AGE : '' ); $chosen = isset( $profile['query']['categories'] ) && is_array( $profile['query']['categories'] ) ? $profile['query']['categories'] : array(); $draft = get_transient( 'akla_draft_' . get_current_user_id() ); if ( is_array( $draft ) && $id === $draft['profile_id'] ) { $chosen = isset( $draft['categories'] ) && is_array( $draft['categories'] ) ? self::category_codes( $draft['categories'] ) : array(); $age_value = isset( $draft['age_range'] ) && is_string( $draft['age_range'] ) ? $draft['age_range'] : $age_value; $age_value = self::LEGACY_AGE === $age_value && '' === $legacy_age ? '' : $age_value; $profile = array_merge( $profile, array( 'name' => $draft['name'], 'slug' => $draft['slug'] ?? $profile['slug'], 'city_label' => $draft['city_label'], 'province' => $draft['province'], 'extra_cities' => isset( $draft['extra_cities'] ) && is_array( $draft['extra_cities'] ) ? $draft['extra_cities'] : array(), 'layout' => 'list' === $draft['layout'] ? 'list' : 'cards', 'hide_empty' => ! empty( $draft['hide_empty'] ), 'brand' => ! empty( $draft['brand'] ), 'links' => array( 'course' => ! empty( $draft['links_course'] ), 'source' => ! empty( $draft['links_source'] ), ), 'query' => array( 'cityId' => absint( $draft['city_id'] ), 'areaId' => ! empty( $draft['area_id'] ) ? absint( $draft['area_id'] ) : null, 'scopeKey' => $draft['scope_key'] ?? null, 'windowDays' => 90 === (int) $draft['window_days'] ? 90 : 30, 'language' => in_array( $draft['language'], self::LANGUAGES, true ) ? $draft['language'] : 'en', 'limit' => max( 1, min( 12, absint( $draft['limit'] ) ) ), 'linksEnabled' => ! empty( $draft['links_course'] ), ), ) ); } $query = $profile['query']; $extra_cities = array_slice( is_array( $profile['extra_cities'] ) ? $profile['extra_cities'] : array(), 0, self::MAX_CITIES - 1 ); $connection = AKLA_Client::connection(); $connection = is_array( $connection ) ? $connection : array(); $connected = AKLA_Client::connected(); $status = self::client_status(); $managed = ! empty( $status['managed_by_constants'] ); $notice = get_transient( 'akla_notice_' . get_current_user_id() ); $saved = $id && is_array( $notice ) && empty( $notice['error'] ) && ( $notice['preview_profile'] ?? '' ) === $id; $coverage_url = 'https://actikidz.com/integrations/wordpress/coverage'; $step = ! $connected ? 1 : ( $profiles ? 3 : 2 ); $guide_requested = self::request_has( 'setup' ); $guide_open = $step < 3 || $guide_requested; $guide_id = 3 === $step ? ( '' !== $id ? $id : (string) array_key_first( $profiles ) ) : ''; delete_transient( 'akla_notice_' . get_current_user_id() ); ?>

    >

    >

      $saved_profile ) : ?>

    > 2 !== $step, 'maxlength' => 200, ) ); self::input( 'akla-slug', 'slug', __( 'Shortcode name', 'actikidz-local-activities' ), $profile['slug'], 'text', array( 'maxlength' => self::SLUG_MAX_LENGTH, 'pattern' => '[A-Za-z0-9\-]*', 'autocomplete' => 'off', 'spellcheck' => 'false', 'aria-describedby' => 'akla-slug-help', ) ); ?>

    'combobox', 'aria-autocomplete' => 'list', 'aria-expanded' => 'false', 'aria-controls' => 'akla-city-results', 'aria-describedby' => 'akla-city-help', 'autocomplete' => 'off', 'required' => true, ) ); ?>

    true ) ); self::select( 'akla-window', 'window_days', __( 'Date window', 'actikidz-local-activities' ), $query['windowDays'], array( 30 => __( 'Next 30 days', 'actikidz-local-activities' ), 90 => __( 'Next 90 days', 'actikidz-local-activities' ), ) ); ?>
    'English', 'fr' => 'Français', 'zh' => '简体中文', 'zh-TW' => '繁體中文', ) ); self::select( 'akla-layout', 'layout', __( 'Layout', 'actikidz-local-activities' ), $profile['layout'], array( 'cards' => __( 'Cards', 'actikidz-local-activities' ), 'list' => __( 'List', 'actikidz-local-activities' ), ) ); ?>
    __( 'Fixed language', 'actikidz-local-activities' ), 'page' => __( 'Follow page language', 'actikidz-local-activities' ), ) ); self::select( 'akla-delivery', 'delivery', __( 'Course loading', 'actikidz-local-activities' ), $profile['delivery'] ?? 'dynamic', array( 'dynamic' => __( 'After page load (recommended)', 'actikidz-local-activities' ), 'server' => __( 'With page HTML (exclude from page cache)', 'actikidz-local-activities' ), ) ); self::select( 'akla-browse', 'browse_mode', __( 'Browsing mode', 'actikidz-local-activities' ), $profile['browse_mode'] ?? 'directory', array( 'directory' => __( 'Directory with course pages', 'actikidz-local-activities' ), 'summary' => __( 'Summary with browse link', 'actikidz-local-activities' ), ) ); self::input( 'akla-fallback-url', 'fallback_url', __( 'Alternative HTTPS link', 'actikidz-local-activities' ), $profile['fallback_url'] ?? '', 'url' ); self::input( 'akla-fallback-text', 'fallback_text', __( 'Unavailable message', 'actikidz-local-activities' ), $profile['fallback_text'] ?? '' ); ?>

    1, 'max' => 12, ) ); ?>

    ">

    ">
    >