>
post_status ) {
return; // unknown/unpublished id — let WP continue to its own normal 404.
}
$config = Map_Types::get_by_post_type( $post->post_type );
if ( ! $config ) {
return; // not one of this plugin's own Spots.
}
$this->enqueue_card_assets();
$categories = $this->spot_categories( $post, $config );
// Same reasoning as maybe_render_preview()'s own $map_html above.
$card_html = $this->render_spot_card( $post, $config, $categories, true );
?>
>
>
to Mapbox on pages that render a map — doesn't
* remove the round trip to fetch the style JSON (loadMapboxStyle() in
* frontend.js), but starts the DNS/TLS handshake earlier, in parallel with page load.
*/
public function add_resource_hints( array $hints, string $relation_type ): array {
if ( 'preconnect' !== $relation_type || ! $this->current_page_has_map() ) {
return $hints;
}
$hints[] = 'https://api.mapbox.com';
$hints[] = 'https://events.mapbox.com'; // Mapbox's own telemetry, called by every style/tile request
return $hints;
}
/**
* Best-effort: only catches a map in the current singular post/page's own
* content — wp_resource_hints fires before content renders, so that's the
* only case knowable early enough. A theme-embedded map just misses the
* hint (never breaks). Irrelevant to the auto-appended Spot card, which
* never loads MapLibre.
*/
private function current_page_has_map(): bool {
if ( ! is_singular() ) {
return false;
}
$post = get_post();
if ( ! $post instanceof \WP_Post ) {
return false;
}
return has_shortcode( $post->post_content, 'mapfield_spots_map' ) || has_block( 'mapfield-spots/map', $post );
}
// ── Shortcode ────────────────────────────────────────────────────────
public function shortcode_map( $atts ): string {
$atts = shortcode_atts( array(
'type' => '',
'list' => 'true',
'height' => '',
'category' => '',
// Other Map Types (id/slug, comma-separated) whose Spots also show as
// read-only pins (PRO) — see render_map()'s docblock.
'overlay' => '',
'title' => 'false',
), $atts, 'mapfield_spots_map' );
return $this->render_map( sanitize_key( $atts['type'] ), array(
'list' => $this->shortcode_bool( $atts['list'] ),
'height' => sanitize_text_field( $atts['height'] ),
'category' => sanitize_text_field( $atts['category'] ),
'overlay' => sanitize_text_field( $atts['overlay'] ),
'title' => $this->shortcode_bool( $atts['title'] ),
) );
}
/** Shortcode attributes are always strings — "false"/"0"/"no" read as false, anything else as true. */
private function shortcode_bool( string $value ): bool {
return ! in_array( strtolower( trim( $value ) ), array( 'false', '0', 'no' ), true );
}
/**
* Replaces $content with the Spot's info card on its own single page.
* Always on: the theme still renders the title, but the card takes over
* the featured image + description for its own two-column layout
* (add_single_spot_body_class() hides the theme's featured-image block).
* in_the_loop()+is_main_query() restrict this to the real page render.
* doing_filter('get_the_excerpt') additionally excludes wp_trim_excerpt()
* (WP 4.5+ reruns 'the_content' before trimming for shortcodes/embeds) —
* without it the whole flattened card leaked into excerpts (/embed/, RSS,
* SEO meta descriptions).
*/
public function maybe_append_spot_card( string $content ): string {
if ( ! is_singular() || ! in_the_loop() || ! is_main_query() || doing_filter( 'get_the_excerpt' ) ) {
return $content;
}
$post = get_post();
if ( ! $post instanceof \WP_Post ) {
return $content;
}
$config = Map_Types::get_by_post_type( $post->post_type );
if ( ! $config ) {
return $content;
}
$this->enqueue_card_assets();
$categories = $this->spot_categories( $post, $config );
// $show_map_link=true: only this, the Spot's real own page — not the
// small popup-preview render below, which reuses this same $native
// layout but is already looking at the map right behind it.
return $this->render_spot_card( $post, $config, $categories, true, true );
}
/**
* Marks a Spot's single page so frontend.css can hide the theme's own
* featured-image block — render_spot_card() already renders that photo
* itself. Scoped to Map Types with the photo field enabled.
*/
public function add_single_spot_body_class( array $classes ): array {
if ( ! is_singular() ) {
return $classes;
}
$config = Map_Types::get_by_post_type( get_post_type() );
if ( $config && ! empty( $config['fields']['photo'] ) ) {
$classes[] = 'mapfield_spots-single';
}
return $classes;
}
/**
* True on a term archive for one of this plugin's category taxonomies, or
* a search results page including a Spot (WP includes all public post
* types in search; a block theme's search template reuses the taxonomy
* archive's own Query Loop markup, confirmed on Twenty Twenty-Five, so
* the same CSS fixes both). Shared by add_archive_body_class() and
* maybe_enqueue_archive_assets().
*/
private function is_own_archive(): bool {
if ( is_tax() ) {
$term = get_queried_object();
if ( ! $term instanceof \WP_Term ) {
return false;
}
foreach ( Map_Types::get_all() as $slug => $config ) {
if ( CPT::taxonomy_slug( $slug ) === $term->taxonomy ) {
return true;
}
}
return false;
}
if ( is_search() ) {
global $wp_query;
foreach ( $wp_query->posts as $post ) {
if ( $post instanceof \WP_Post && Map_Types::get_by_post_type( $post->post_type ) ) {
return true;
}
}
}
return false;
}
/** Marks a category archive or Spot-including search page so frontend.css can reflow the theme's Query Loop into two columns. */
public function add_archive_body_class( array $classes ): array {
if ( $this->is_own_archive() ) {
$classes[] = 'mapfield_spots-archive';
}
return $classes;
}
/** Just the stylesheet — archives/search never need enqueue_card_assets()'s gallery/description JS. */
public function maybe_enqueue_archive_assets(): void {
if ( $this->is_own_archive() ) {
wp_enqueue_style( 'mapfield-spots-frontend', MAPFIELD_SPOTS_URL . 'assets/frontend.css', array( 'mapfield-spots-palette' ), MAPFIELD_SPOTS_VERSION );
}
}
// ── Gutenberg block ──────────────────────────────────────────────────
public function register_block(): void {
wp_register_script(
'mapfield-spots-block',
MAPFIELD_SPOTS_URL . 'assets/block.js',
array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n', 'wp-hooks' ),
MAPFIELD_SPOTS_VERSION,
true
);
// block.js's wp.i18n.__() calls need this — unlike PHP's __()/_e(), JS reads
// a separate compiled JSON catalog per script handle, auto-discovered off
// this plugin's own Domain Path header.
wp_set_script_translations( 'mapfield-spots-block', 'mapfield-spots' );
$choices = array();
foreach ( Map_Types::get_all() as $slug => $config ) {
$choices[] = array(
'slug' => $slug,
'label' => $config['label_plural'] ?: $slug,
// Only when categories are enabled — also determines whether the
// block's Category control renders at all (see block.js).
'categories' => ! empty( $config['fields']['category'] ) ? $config['categories'] : array(),
);
}
wp_localize_script( 'mapfield-spots-block', 'MapfieldSpotsBlockData', array(
'mapTypes' => $choices,
) );
register_block_type( 'mapfield-spots/map', array(
'editor_script' => 'mapfield-spots-block',
'render_callback' => function ( $attributes ) {
return $this->render_map( sanitize_key( $attributes['mapType'] ?? '' ), array(
'list' => ! empty( $attributes['showList'] ),
'height' => sanitize_text_field( $attributes['height'] ?? '' ),
'category' => sanitize_text_field( $attributes['category'] ?? '' ),
'overlay' => sanitize_text_field( $attributes['overlay'] ?? '' ),
'title' => ! empty( $attributes['title'] ),
'align' => sanitize_key( $attributes['align'] ?? '' ),
) );
},
'attributes' => array(
'mapType' => array( 'type' => 'string', 'default' => '' ),
'showList' => array( 'type' => 'boolean', 'default' => true ),
'height' => array( 'type' => 'string', 'default' => '' ),
'category' => array( 'type' => 'string', 'default' => '' ),
'overlay' => array( 'type' => 'string', 'default' => '' ),
'title' => array( 'type' => 'boolean', 'default' => false ),
),
// Matched by block.js's `supports: { align: [...] }` (shows the toolbar
// control) and makes WordPress recognize $attributes['align'] for a
// plain register_block_type() array registration (no block.json).
// Does NOT auto-apply an align class to render_callback's output —
// that's a block.json+render.php mechanism; render_map() applies the
// class by hand instead (see $align_class there).
'supports' => array(
'align' => array( 'wide', 'full' ),
),
) );
}
// ── Shared rendering ─────────────────────────────────────────────────
/**
* @param array{list?: bool, height?: string, category?: string, overlay?: string, title?: bool, align?: string} $args
* list: show the locations list panel (default true).
* height: CSS length overriding the wrap's default 640px height.
* category: comma-separated category slugs to filter Spots by.
* overlay: comma-separated other Map Types (PRO, by id/public_slug)
* whose Spots also show as read-only pins (mapfield_spots_overlay_layers,
* see Mapfield_Spots_Pro\Overlay) — a per-embed choice, not a stored
* Map Type setting.
* title: show this map's own name as a heading above it (default false).
* align: 'wide'|'full'|'' — the block's alignment toolbar choice; no shortcode equivalent.
*/
public function render_map( string $map_type_slug, array $args = array() ): string {
$config = Map_Types::get( $map_type_slug );
if ( ! $config ) {
if ( current_user_can( 'manage_options' ) ) {
return '
';
}
return '';
}
// $map_type_slug can be id or public_slug; $id below is always the real
// internal id (post_type/taxonomy/Zones keys use that).
$id = $config['slug'];
if ( $this->is_page_builder_edit_mode() ) {
return $this->render_editor_placeholder( $config );
}
$args = wp_parse_args( $args, array( 'list' => true, 'height' => '', 'category' => '', 'overlay' => '', 'title' => false, 'align' => '', 'focus_spot' => 0 ) );
$height = $this->sanitize_css_length( $args['height'] );
$categories_filter = array_filter( array_map( 'sanitize_key', explode( ',', $args['category'] ) ) );
$show_map_title = ! empty( $args['title'] );
// The block's "Wide/Full width" toolbar control (shortcode has no
// equivalent). Sanitized against the fixed set so a crafted $args can't
// emit an arbitrary class.
$align_class = in_array( $args['align'], array( 'wide', 'full' ), true ) ? ' align' . $args['align'] : '';
// Per-embed choice, not a stored Map_Types setting. Resolution (token
// parsing) and layer-building are both PRO (mapfield_spots_overlay_layers,
// hooked by Mapfield_Spots_Pro\Overlay) — no filter result is an empty array.
$overlay_layers = $args['overlay'] ? apply_filters( 'mapfield_spots_overlay_layers', array(), $args['overlay'], $id ) : array();
$this->enqueue_assets( $config );
// Extensibility hook PRO fills (Mapfield_Spots_Pro\Routing::maybe_enqueue()),
// fired before /footer print so PRO can enqueue its own assets in time.
do_action( 'mapfield_spots_render_map', $id, $config );
// Unconditional: needed for overlay Spots, list-off, and the mobile compact
// card layout's own expand button — every path can need it now.
wp_enqueue_script( 'mapfield-spots-spot-preview-modal', MAPFIELD_SPOTS_URL . 'assets/spot-preview-modal.js', array( 'pimp-scrollbar' ), MAPFIELD_SPOTS_VERSION, true );
$query_args = array(
'post_type' => CPT::post_type_slug( $id ),
'posts_per_page' => -1,
'post_status' => 'publish',
// Shown as soon as real coordinates exist — position is a normal post
// field, no separate "confirmed" state (see Location_Field's docblock).
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- there's no alternative for "has real coordinates" without a dedicated indexed table; a Map Type's location count is small (typically tens to low hundreds), so the plain meta_query is an acceptable tradeoff here.
'meta_query' => array(
'relation' => 'AND',
array( 'key' => '_mapfield_spots_lat', 'value' => '', 'compare' => '!=' ),
array( 'key' => '_mapfield_spots_lng', 'value' => '', 'compare' => '!=' ),
),
);
if ( $categories_filter && ! empty( $config['fields']['category'] ) ) {
$query_args['tax_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- one embed's worth of Spots, same small scale as the meta_query above
array(
'taxonomy' => CPT::taxonomy_slug( $id ),
'field' => 'slug',
'terms' => $categories_filter, // 'terms' as an array already means "any of these" (OR), no separate 'operator' needed for that
),
);
}
$posts = get_posts( $query_args );
$features = array();
$list_html = '';
foreach ( $posts as $post ) {
$lat = (float) get_post_meta( $post->ID, '_mapfield_spots_lat', true );
$lng = (float) get_post_meta( $post->ID, '_mapfield_spots_lng', true );
if ( ! $lat || ! $lng ) {
continue;
}
$categories = $this->spot_categories( $post, $config );
if ( $categories_filter ) {
// Move filter-matched categories to the front so the pin colors by the
// filtered category, not some other lower-ID one the Spot also has
// (e.g. filtering "Bistrot" but the pin still shows Gastronomique-red).
// array_filter() not usort(): guaranteed order-preserving on PHP 7.4
// (usort() only got that in PHP 8).
$matched = array_filter( $categories, function ( $cat ) use ( $categories_filter ) {
return in_array( $cat['slug'], $categories_filter, true );
} );
$categories = array_merge( $matched, array_diff_key( $categories, $matched ) );
}
$color = $categories[0]['color'] ?? '#E03137'; // == categoryColors[0] when there are any — kept as its own key since it's also the no-category fallback. Matches --mapfield_spots-alert.
$features[] = array(
'id' => $post->ID,
'lat' => $lat,
'lng' => $lng,
// html_entity_decode(): get_the_title() runs through wptexturize()
// (apostrophe → ’), fine for the list (HTML) but this title is
// set via JS textContent for the marker label, which doesn't parse entities.
'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES, 'UTF-8' ), // shown above the pin once it's the active/selected marker
'color' => $color,
'categorySlugs' => wp_list_pluck( $categories, 'slug' ),
'categoryColors' => wp_list_pluck( $categories, 'color' ),
// Only used by frontend.js when the list panel is off; built for every
// Spot regardless (negligible payload vs. extra branching).
'previewUrl' => home_url( '/?mapfield_spots_spot_preview=' . $post->ID ),
);
if ( $args['list'] ) {
$list_html .= $this->render_spot_card( $post, $config, $categories, false );
}
}
$container_id = 'mapfield_spots-map-' . $id . '-' . wp_unique_id();
$mapbox_settings = Settings::get_mapbox_settings();
$marker = Marker_Library::resolve( $config['map'] );
wp_add_inline_script( 'mapfield-spots-frontend', sprintf(
'window.MapfieldSpotsMaps = window.MapfieldSpotsMaps || []; window.MapfieldSpotsMaps.push(%s);',
wp_json_encode( array(
'containerId' => $container_id,
// Read by startMap()'s style-load failure handler to pick the right
// i18n string — never trust a client-side check for this.
'isAdmin' => current_user_can( 'manage_options' ),
'token' => $mapbox_settings['token'],
'style' => $config['map']['style_url'] ?: $mapbox_settings['style_url'],
'center' => array( $config['map']['center'][1], $config['map']['center'][0] ),
'zoom' => $config['map']['zoom'],
// When true, frontend.js skips auto-fitting bounds to the Spots/Zones
// shown and keeps this center/zoom as the real starting view instead.
'lockInitialView' => ! empty( $config['map']['initial_view_enabled'] ),
// Per-map default now (was a site-wide Settings toggle) — the Map
// Type's own "3D buildings" toolbar checkbox.
'showBuildings' => ! empty( $config['toolbar']['buildings3d'] ),
// Default true — matches the plugin's existing behaviour before this toggle existed.
'zonePopup' => ! empty( $config['fields']['zone_popup'] ),
'markerSvg' => $marker['markup'],
// A custom photo/logo has no corner for dots like a drawn pin —
// buildMarkerElement() skips them for custom markers.
'markerIsCustom' => $marker['is_custom'],
// Read by frontend.js: with the list off, a marker click also opens
// the small preview popup (+ "View details"), otherwise the only way
// to reach a Spot's details.
'showList' => (bool) $args['list'],
// A Spot's own single page links here ("View on map") to fly
// straight to it instead of landing on the full overview.
'focusSpotId' => (int) $args['focus_spot'],
// Read by PRO's routing.js to offer Circuit/travel-times controls per
// map instance (a page can mix maps with Directions on/off).
'directionsEnabled' => ! empty( $config['fields']['directions'] ),
// Front-end map tools this Map Type's admin enabled (see "Map
// toolbar" tab). toolbarCircuit + directionsEnabled must both be
// true for PRO's routing.js.
'toolbarFullscreen' => ! empty( $config['toolbar']['fullscreen'] ),
'toolbarBuildings3d' => ! empty( $config['toolbar']['buildings3d'] ),
'toolbarSatellite' => ! empty( $config['toolbar']['satellite'] ),
'toolbarTerrain' => ! empty( $config['toolbar']['terrain'] ),
'toolbarCircuit' => ! empty( $config['toolbar']['circuit'] ),
'features' => $features,
'overlayLayers' => $overlay_layers,
// Same extensibility-hook pattern as above (Mapfield_Spots_Pro\Zones::get_zones_geojson()).
'zones' => apply_filters( 'mapfield_spots_map_zones', array(), $id ),
) )
) );
$show_list = $args['list'] && $features;
// A category filter already excludes other categories from the query, so a
// legend that "filters" among them would be broken — skip it.
$show_legend = ! $categories_filter && ! empty( $config['fields']['category'] ) && ! empty( $config['categories'] ) && ! empty( $config['toolbar']['category_legend'] );
// Map_Types' own theme config, applied as inline custom properties (see
// Map_Types docblock); only emitted when customized so untouched Map
// Types keep identical markup.
$theme_defaults = Map_Types::default_config()['theme'];
$wrap_style = array();
if ( $height ) {
$wrap_style[] = '--mapfield_spots-map-height:' . $height;
}
if ( $config['theme']['list_bg'] !== $theme_defaults['list_bg'] ) {
$wrap_style[] = '--mapfield_spots-list-bg:' . $config['theme']['list_bg'];
}
if ( $config['theme']['item_bg'] !== $theme_defaults['item_bg'] ) {
$wrap_style[] = '--mapfield_spots-list-item-bg:' . $config['theme']['item_bg'];
}
if ( $config['theme']['link_color'] !== $theme_defaults['link_color'] ) {
$wrap_style[] = '--mapfield_spots-link:' . $config['theme']['link_color'];
}
if ( $config['theme']['border_color'] !== $theme_defaults['border_color'] ) {
$wrap_style[] = '--mapfield_spots-medium:' . $config['theme']['border_color'];
}
if ( $config['theme']['accent_color'] !== $theme_defaults['accent_color'] ) {
$wrap_style[] = '--mapfield_spots-alert:' . $config['theme']['accent_color'];
}
if ( $config['theme']['text_color'] !== $theme_defaults['text_color'] ) {
$wrap_style[] = '--mapfield_spots-text:' . $config['theme']['text_color'];
}
if ( (int) $config['theme']['radius'] !== (int) $theme_defaults['radius'] ) {
$wrap_style[] = '--mapfield_spots-radius:' . (int) $config['theme']['radius'] . 'px';
}
// sanitize() already restricts marker_size to these 3 presets + 'custom'
// (PRO); only emitted when it differs from the default, same as above.
$marker_sizes = array(
'small' => 25,
'medium' => 35,
'large' => 45,
);
if ( 'custom' === $config['map']['marker_size'] ) {
// The declaration itself (not just whether it's allowed) comes from
// PRO, re-checked so a config saved under PRO still falls back
// cleanly if PRO is later deactivated — same pattern as the custom
// marker/rating icon resolution above.
$custom_css = apply_filters( 'mapfield_spots_custom_marker_size_css', '', (int) ( $config['map']['marker_size_custom_px'] ?? 0 ) );
if ( $custom_css ) {
$wrap_style[] = $custom_css;
}
} elseif ( 'medium' !== $config['map']['marker_size'] ) {
$wrap_style[] = '--mapfield_spots-marker-size:' . $marker_sizes[ $config['map']['marker_size'] ] . 'px';
}
ob_start();
?>
isn't always there. ?>
>
*/
private function spot_categories( \WP_Post $post, array $config ): array {
$categories = array();
if ( empty( $config['fields']['category'] ) ) {
return $categories;
}
$terms = get_the_terms( $post->ID, CPT::taxonomy_slug( $config['slug'] ) );
if ( empty( $terms ) || is_wp_error( $terms ) ) {
return $categories;
}
// Deterministic order (term_id): a Spot can have several categories, but a
// marker only shows one colour, so the lowest term_id wins.
usort( $terms, function ( $a, $b ) { return $a->term_id <=> $b->term_id; } );
foreach ( $terms as $term ) {
$categories[] = array(
'slug' => $term->slug, // matches a Map Type category's own 'slug' — see CPT::register_taxonomy(), which creates each term with that exact slug
'label' => $term->name,
'color' => get_term_meta( $term->term_id, 'mapfield_spots_color', true ) ?: '#E03137',
);
}
return $categories;
}
/**
* Renders one Spot's info card, every field its Map Type has enabled.
* Shared by both rendering paths so a new field only needs markup in one place.
*
* $native=false: list item in render_map()'s panel — clickable, synced to
* its marker via data-post-id, truncated description with "Read more".
* $native=true: auto-appended to the Spot's single page — theme still
* renders the title; photo/description are rendered by the card itself
* (theme's own featured-image block hidden via add_single_spot_body_class())
* into two columns (.__media / .__info, see frontend.css).
*/
private function render_spot_card( \WP_Post $post, array $config, array $categories, bool $native = false, bool $show_map_link = false ): string {
$show_title = ! $native;
$thumb = ! empty( $config['fields']['photo'] ) && has_post_thumbnail( $post ) ? get_the_post_thumbnail_url( $post, $native ? 'large' : 'medium' ) : '';
// Full-res for the lightbox's data-full — deliberately not $thumb itself,
// which is the smaller 'medium' size for the inline list display.
$thumb_full = $thumb ? get_the_post_thumbnail_url( $post, 'large' ) : '';
$website = ! empty( $config['fields']['website'] ) ? get_post_meta( $post->ID, '_mapfield_spots_website', true ) : '';
$phone = ! empty( $config['fields']['phone'] ) ? get_post_meta( $post->ID, '_mapfield_spots_phone', true ) : '';
$cta_label = ! empty( $config['fields']['cta'] ) ? get_post_meta( $post->ID, '_mapfield_spots_cta_label', true ) : '';
$cta_url = ! empty( $config['fields']['cta'] ) ? get_post_meta( $post->ID, '_mapfield_spots_cta_url', true ) : '';
$gallery_ids = ! empty( $config['fields']['gallery'] ) ? Gallery_Field::sanitize_ids( get_post_meta( $post->ID, '_mapfield_spots_gallery', true ) ) : array();
$hours_groups = array();
if ( ! empty( $config['fields']['hours'] ) ) {
$hours = Hours_Field::sanitize_hours( get_post_meta( $post->ID, '_mapfield_spots_hours', true ) );
if ( Hours_Field::has_any_data( $hours ) ) {
$hours_groups = Hours_Field::grouped_for_display( $hours );
}
}
$social_links = ! empty( $config['fields']['social'] ) ? Social_Field::sanitize_links( get_post_meta( $post->ID, '_mapfield_spots_social', true ) ) : array();
$rating_level = ! empty( $config['fields']['rating'] ) ? Rating_Field::sanitize_level( get_post_meta( $post->ID, '_mapfield_spots_rating', true ) ) : 0;
$rating_symbol_key = $config['rating']['symbol'] ?? 'star';
$rating_label = $config['rating']['label'] ?: __( 'Rating', 'mapfield-spots' );
$lat = (float) get_post_meta( $post->ID, '_mapfield_spots_lat', true );
$lng = (float) get_post_meta( $post->ID, '_mapfield_spots_lng', true );
// Generated from the same lat/lng as the marker — nothing extra for the editor to fill in.
$directions_url = ! empty( $config['fields']['directions'] ) && $lat && $lng ? sprintf( 'https://www.google.com/maps/dir/?api=1&destination=%s', rawurlencode( $lat . ',' . $lng ) ) : '';
$gallery_items = array();
foreach ( $gallery_ids as $gallery_id ) {
$gallery_thumb = wp_get_attachment_image_url( $gallery_id, Gallery_Field::THUMB_SIZE );
$gallery_full = wp_get_attachment_image_url( $gallery_id, 'large' );
if ( $gallery_thumb && $gallery_full ) {
$gallery_items[] = array( 'thumb' => $gallery_thumb, 'full' => $gallery_full );
}
}
// Plain-text truncation avoids cutting mid-tag; full HTML only shown via
// "Read more" ($native skips the description entirely).
$desc_limit = 150;
$desc_plain = wp_strip_all_tags( get_the_content( null, false, $post ) );
$desc_html = wp_kses_post( wpautop( get_the_content( null, false, $post ) ) );
$needs_more = ! $native && mb_strlen( $desc_plain ) > $desc_limit;
$wrapper_class = $native ? 'mapfield_spots-spot-card' : 'mapfield_spots-map-list__item';
// Built once, echoed wherever the layout needs it — inline for the list
// card, pulled into its own media column for $native.
$gallery_html = '';
if ( $gallery_items ) {
ob_start();
?>
$gallery_item ) :
// The 7 visible thumbs stay real images; the 8th doubles as a "+N"
// tile (still a real image underneath) — CSS hides the rest, the
// lightbox still has them all.
$is_overflow = ( 7 === $gi && count( $gallery_items ) > 7 );
?>
>
for no-JS/new-tab; spot-map-modal.js intercepts the click to open it in a popup instead of navigating away. ?>
enqueue_card_assets();
$this->enqueue_map_only_assets();
}
/** Card CSS/JS — needed everywhere a card can appear, with or without a map. */
private function enqueue_card_assets(): void {
// Not loaded on the front end by default — needed here for the Website/Phone icons.
wp_enqueue_style( 'dashicons' );
wp_enqueue_style( 'mapfield-spots-frontend', MAPFIELD_SPOTS_URL . 'assets/frontend.css', array( 'mapfield-spots-palette' ), MAPFIELD_SPOTS_VERSION );
// Shared lightbox for every card's gallery thumbnails — standalone since
// it's unrelated to the map itself.
wp_enqueue_script( 'mapfield-spots-gallery-lightbox', MAPFIELD_SPOTS_URL . 'assets/gallery-lightbox.js', array(), MAPFIELD_SPOTS_VERSION, true );
wp_enqueue_script( 'mapfield-spots-description-toggle', MAPFIELD_SPOTS_URL . 'assets/description-toggle.js', array(), MAPFIELD_SPOTS_VERSION, true );
// "View on map" popup (only rendered with $show_map_link, but cheap enough to always enqueue here).
wp_enqueue_script( 'mapfield-spots-spot-map-modal', MAPFIELD_SPOTS_URL . 'assets/spot-map-modal.js', array(), MAPFIELD_SPOTS_VERSION, true );
}
/**
* MapLibre + the map canvas/list JS — only needed for render_map(), not
* for the auto-appended Spot page card, which never draws a map at
* all. Kept separate so that card doesn't pull in MapLibre's own
* JS/CSS for nothing.
*/
private function enqueue_map_only_assets(): void {
wp_enqueue_style( 'maplibre-gl', MAPFIELD_SPOTS_URL . 'assets/vendor/maplibre-gl/maplibre-gl.css', array(), '4.7.1' );
wp_enqueue_script( 'maplibre-gl', MAPFIELD_SPOTS_URL . 'assets/vendor/maplibre-gl/maplibre-gl.js', array(), '4.7.1', true );
// Reusable across the plugin (any screen, admin or front-end) —
// just add the pimp-scrollbar class to an element and enqueue
// these two files, no other wiring needed.
wp_enqueue_style( 'pimp-scrollbar', MAPFIELD_SPOTS_URL . 'assets/pimp-scrollbar.css', array(), MAPFIELD_SPOTS_VERSION );
wp_enqueue_script( 'pimp-scrollbar', MAPFIELD_SPOTS_URL . 'assets/pimp-scrollbar.js', array(), MAPFIELD_SPOTS_VERSION, true );
wp_enqueue_script( 'mapfield-spots-frontend', MAPFIELD_SPOTS_URL . 'assets/frontend.js', array( 'maplibre-gl', 'pimp-scrollbar' ), MAPFIELD_SPOTS_VERSION, true );
wp_localize_script( 'mapfield-spots-frontend', 'MapfieldSpotsFrontendI18n', array(
'rotateHint' => __( 'Hold and drag to rotate in 3D', 'mapfield-spots' ),
// Same strings as the sidebar list card's own Read more/less
// toggle (see render_map() below) — a zone popup's description
// (PRO) reuses them for its own truncation, built in JS since
// that popup's HTML doesn't exist until a zone is clicked.
'readMore' => __( 'Read more', 'mapfield-spots' ),
'readLess' => __( 'Read less', 'mapfield-spots' ),
// Overlay Spot popup's own "open full page" button + its modal
// (PRO — see Mapfield_Spots_Pro\Overlay, spot-preview-modal.js).
'viewFullPage' => __( 'View details', 'mapfield-spots' ),
'close' => __( 'Close', 'mapfield-spots' ),
// startMap()'s own style-load failure (e.g. an invalid/expired
// Mapbox token) — %s is the raw fetch()/MapLibre error text,
// left untranslated on purpose (it's the same technical detail
// an admin would need to search for, e.g. an HTTP status code).
// Everyone else gets the plain, translated one instead — never
// this raw detail, same admin-vs-everyone split already used
// for the "unknown map type"/"map inactive" messages above.
/* translators: %s: the raw fetch()/MapLibre error message (e.g. an HTTP status code or network error) */
'mapLoadErrorAdmin' => __( 'Mapfield Spots: could not load the map style (%s).', 'mapfield-spots' ),
'mapLoadErrorVisitor' => __( 'This map is temporarily unavailable.', 'mapfield-spots' ),
) );
}
}