config). */ class Map_Types { const OPTION = 'mapfield_spots_map_types'; // Max length of 'public_slug' (human-facing name), unrelated to the CPT // slug limit — see CPT::max_map_type_slug_length(). const PUBLIC_SLUG_MAX_LENGTH = 60; /** @return array */ public static function get_all(): array { $types = get_option( self::OPTION, array() ); if ( ! is_array( $types ) ) { return array(); } // Merge defaults so configs missing keys added by a later version don't need per-caller guards. foreach ( $types as $slug => $config ) { $types[ $slug ] = self::with_defaults( $slug, $config ); } return $types; } /** $needle: internal id (array key) or public_slug, tried in that order. */ public static function get( string $needle ): ?array { $types = self::get_all(); if ( isset( $types[ $needle ] ) ) { return $types[ $needle ]; } foreach ( $types as $config ) { if ( $config['public_slug'] === $needle ) { return $config; } } return null; } private static function with_defaults( string $slug, array $config ): array { $defaults = self::default_config(); $config = array_merge( $defaults, $config ); $config['fields'] = array_merge( $defaults['fields'], (array) ( $config['fields'] ?? array() ) ); $config['toolbar'] = array_merge( $defaults['toolbar'], (array) ( $config['toolbar'] ?? array() ) ); $config['map'] = array_merge( $defaults['map'], (array) ( $config['map'] ?? array() ) ); $config['rating'] = array_merge( $defaults['rating'], (array) ( $config['rating'] ?? array() ) ); $config['theme'] = array_merge( $defaults['theme'], (array) ( $config['theme'] ?? array() ) ); $config['categories'] = $config['categories'] ?? array(); $config['acf_fields'] = (array) ( $config['acf_fields'] ?? array() ); // Falls back to the id for configs saved before public_slug existed. empty() // rather than ?: to avoid a PHP8 undefined-key warning when the key is absent. $config['public_slug'] = empty( $config['public_slug'] ) ? $slug : $config['public_slug']; return $config; } /** Map Type for a given already-prefixed CPT slug, or null. */ public static function get_by_post_type( string $post_type ): ?array { foreach ( self::get_all() as $slug => $config ) { if ( CPT::post_type_slug( $slug ) === $post_type ) { return $config; } } return null; } /** * @param string $existing_id Existing Map Type id, or '' to generate a new one. * @return string The id actually saved under. */ public static function save( string $existing_id, array $config ): string { $types = self::get_all(); $id = ( $existing_id && isset( $types[ $existing_id ] ) ) ? $existing_id : self::generate_id( array_keys( $types ) ); // Falls back to the id when left blank (pre-public_slug behaviour). $public_slug = sanitize_key( $config['public_slug'] ?? '' ) ?: $id; // public_slug is free text now (not a unique array key like before), so // silently dedupe collisions rather than surfacing a validation error. $other_public_slugs = array(); foreach ( $types as $other_id => $other_config ) { if ( $other_id !== $id ) { $other_public_slugs[] = $other_config['public_slug'] ?? $other_id; } } $base = $public_slug; $n = 2; while ( in_array( $public_slug, $other_public_slugs, true ) ) { $public_slug = $base . '-' . $n; ++$n; } $config['public_slug'] = $public_slug; $types[ $id ] = self::sanitize( $id, $config ); update_option( self::OPTION, $types ); update_option( 'mapfield_spots_flush_rewrite', 1 ); return $id; } /** * Short random WP-safe internal id, becomes the CPT/taxonomy name via * CPT::PREFIX — never shown to the user. */ private static function generate_id( array $existing_ids ): string { do { $id = strtolower( wp_generate_password( 6, false, false ) ); } while ( in_array( $id, $existing_ids, true ) ); return $id; } public static function delete( string $slug ): void { $types = self::get_all(); unset( $types[ $slug ] ); update_option( self::OPTION, $types ); update_option( 'mapfield_spots_flush_rewrite', 1 ); } /** * PRO adds its own default sub-values here (zones/zone_popup, circuit, * acf_fields*, custom marker/marker_size/rating icon) via * mapfield_spots_default_map_type_config — see class-zones.php, * class-routing.php, class-acf-display.php, class-customization.php. */ public static function default_config(): array { return apply_filters( 'mapfield_spots_default_map_type_config', array( 'slug' => '', 'label_singular' => '', 'label_plural' => '', 'fields' => array( 'category_badges' => true, 'photo' => true, 'description' => true, 'category' => false, 'website' => false, 'phone' => false, 'cta' => false, 'gallery' => false, 'hours' => false, 'social' => false, 'rating' => false, 'directions' => false, ), 'toolbar' => array( // Was always shown whenever the map had categories; defaults true so // existing maps keep looking the same now that it's a real toggle. 'category_legend' => true, 'fullscreen' => true, // Tilts the map to 45° on load whenever enabled (see frontend.js) — off // by default so a new map starts flat/readable; a site owner opts in. 'buildings3d' => false, 'satellite' => true, // New tool, not a back-compat concern, so false is the real default. 'terrain' => false, ), 'categories' => array(), 'map' => array( 'style_url' => 'mapbox://styles/mapbox/streets-v12', // When false, the front end ignores center/zoom below and auto-fits // to the Spots/Zones shown instead — see Frontend::localize_map() // and frontend.js's startMap(). Admin UI: Map toolbar tab. 'initial_view_enabled' => false, 'center' => array( 46.6, 2.4 ), // approx. center of France 'zoom' => 5, 'marker' => Marker_Library::default_id(), 'marker_size' => 'medium', ), 'rating' => array( 'label' => '', 'symbol' => 'star', ), // Optional re-theming, applied as inline custom properties per Map Type // (Frontend::render_map()); also read live by enabled-fields-preview.js. 'theme' => array( 'list_bg' => '#f9fafb', 'item_bg' => '#ffffff', 'link_color' => '#0783be', 'border_color' => '#d8ebf5', 'accent_color' => '#e03137', 'text_color' => '#344054', 'radius' => 8, ), ) ); } private static function sanitize( string $slug, array $config ): array { $defaults = self::default_config(); $config = wp_parse_args( $config, $defaults ); // $slug and public_slug arrive already resolved/de-duplicated by save() above. $config['slug'] = $slug; $config['label_singular'] = sanitize_text_field( $config['label_singular'] ); $config['label_plural'] = sanitize_text_field( $config['label_plural'] ); $config['fields'] = array( 'category_badges' => ! empty( $config['fields']['category_badges'] ), 'photo' => ! empty( $config['fields']['photo'] ), 'description' => ! empty( $config['fields']['description'] ), 'category' => ! empty( $config['fields']['category'] ), 'website' => ! empty( $config['fields']['website'] ), 'phone' => ! empty( $config['fields']['phone'] ), 'cta' => ! empty( $config['fields']['cta'] ), 'gallery' => ! empty( $config['fields']['gallery'] ), 'hours' => ! empty( $config['fields']['hours'] ), 'social' => ! empty( $config['fields']['social'] ), 'rating' => ! empty( $config['fields']['rating'] ), 'directions' => ! empty( $config['fields']['directions'] ), ); $config['toolbar'] = array( 'category_legend' => ! empty( $config['toolbar']['category_legend'] ), 'fullscreen' => ! empty( $config['toolbar']['fullscreen'] ), 'buildings3d' => ! empty( $config['toolbar']['buildings3d'] ), 'satellite' => ! empty( $config['toolbar']['satellite'] ), 'terrain' => ! empty( $config['toolbar']['terrain'] ), ); $categories = array(); foreach ( (array) ( $config['categories'] ?? array() ) as $cat ) { if ( empty( $cat['label'] ) ) { continue; } $categories[] = array( 'slug' => sanitize_title( $cat['slug'] ?? $cat['label'] ), 'label' => sanitize_text_field( $cat['label'] ), 'color' => sanitize_hex_color( $cat['color'] ?? '' ) ?: '#E03137', // Matches --mapfield_spots-alert. ); } $config['categories'] = $categories; $available_markers = Marker_Library::get_all(); $requested_marker = sanitize_key( $config['map']['marker'] ?? '' ); $requested_marker_size = sanitize_key( $config['map']['marker_size'] ?? '' ); $config['map'] = array( // Blank only reachable on configs saved before this field existed; falls // back to the built-in default style. 'style_url' => Settings::sanitize_style_url( $config['map']['style_url'] ?? '' ) ?: 'mapbox://styles/mapbox/streets-v12', 'initial_view_enabled' => ! empty( $config['map']['initial_view_enabled'] ), 'center' => array( (float) ( $config['map']['center'][0] ?? 46.6 ), (float) ( $config['map']['center'][1] ?? 2.4 ), ), // (int) was dropping the decimal precision "Initial zoom" needs (e.g. 10.5 → 10). 'zoom' => max( 1, min( 20, (float) ( $config['map']['zoom'] ?? 5 ) ) ), // 'custom' (PRO) isn't a library id, so it just falls back here — PRO's // own filter (mapfield_spots_sanitize_map_type, below) overrides this // once it validates the request. 'marker' => isset( $available_markers[ $requested_marker ] ) ? $requested_marker : Marker_Library::default_id(), // Same "PRO's 'custom' value just falls back here" pattern as 'marker' above. 'marker_size' => in_array( $requested_marker_size, array( 'small', 'medium', 'large' ), true ) ? $requested_marker_size : 'medium', ); $requested_symbol = sanitize_key( $config['rating']['symbol'] ?? '' ); $config['rating'] = array( 'label' => sanitize_text_field( $config['rating']['label'] ?? '' ), // Rating_Field::CUSTOM_KEY (PRO) excluded here for the same reason as // 'marker' above — overridden below by PRO's own filter when valid. 'symbol' => ( Rating_Field::CUSTOM_KEY !== $requested_symbol && in_array( $requested_symbol, Rating_Field::all_symbol_keys(), true ) ) ? $requested_symbol : 'star', ); $config['theme'] = array( 'list_bg' => sanitize_hex_color( $config['theme']['list_bg'] ?? '' ) ?: $defaults['theme']['list_bg'], 'item_bg' => sanitize_hex_color( $config['theme']['item_bg'] ?? '' ) ?: $defaults['theme']['item_bg'], 'link_color' => sanitize_hex_color( $config['theme']['link_color'] ?? '' ) ?: $defaults['theme']['link_color'], 'border_color' => sanitize_hex_color( $config['theme']['border_color'] ?? '' ) ?: $defaults['theme']['border_color'], 'accent_color' => sanitize_hex_color( $config['theme']['accent_color'] ?? '' ) ?: $defaults['theme']['accent_color'], 'text_color' => sanitize_hex_color( $config['theme']['text_color'] ?? '' ) ?: $defaults['theme']['text_color'], // 40 is a generous sanity-check ceiling, not a design requirement. 'radius' => max( 0, min( 40, (int) ( $config['theme']['radius'] ?? $defaults['theme']['radius'] ) ) ), ); // PRO reads $_POST and adds/validates its own keys here (zones/zone_popup, // circuit, acf_fields*, custom marker/marker_size/rating icon) — see // class-zones.php, class-routing.php, class-acf-display.php, // class-customization.php. This plugin only keeps what's sanitized above. return apply_filters( 'mapfield_spots_sanitize_map_type', $config, $slug ); } }