label. * * The single source of truth: the shortcode/block/Elementor whitelists, * the Settings dropdown and the wrapper class all read this list, so a * new skin only needs an entry here plus its CSS block in front.css. * * @return array */ public static function card_styles() { $styles = array( 'classic' => __( 'Classic', 'advanced-testimonial' ), 'modern' => __( 'Modern', 'advanced-testimonial' ), 'minimal' => __( 'Minimal', 'advanced-testimonial' ), 'bubble' => __( 'Bubble', 'advanced-testimonial' ), 'bold' => __( 'Bold', 'advanced-testimonial' ), 'glass' => __( 'Glass', 'advanced-testimonial' ), 'gradient' => __( 'Gradient', 'advanced-testimonial' ), 'outline' => __( 'Outline', 'advanced-testimonial' ), 'retro' => __( 'Retro', 'advanced-testimonial' ), ); /** * Filter the available card skins. * * @param array $styles Slug => label. */ return apply_filters( 'advanced_testimonial_card_styles', $styles ); } /** * Default display attributes. * * @return array */ public static function defaults() { return array( 'layout' => 'grid', 'style' => '', 'title' => '', 'width' => '', 'columns' => 3, 'limit' => 9, 'group' => '', 'ids' => '', 'order' => 'desc', 'orderby' => 'date', 'verified' => false, 'show_rating' => true, 'show_image' => true, 'show_company' => true, 'show_designation' => true, 'show_location' => true, 'show_date' => false, 'show_verified' => true, 'show_website' => true, 'show_headline' => true, 'show_video' => true, 'show_filter' => false, 'read_more' => false, 'load_more' => false, 'autoplay' => 0, 'speed' => '', 'card_width' => 0, 'fade' => true, 'direction' => '', ); } /** * Render testimonials and return the HTML string. * * @param array $atts Raw display attributes. * @return string */ public static function render( $atts ) { $atts = self::normalize( (array) $atts ); $query = ( new Query() )->get( $atts ); if ( ! $query->have_posts() ) { return self::empty_message(); } $items = array(); foreach ( $query->posts as $post ) { $items[] = self::build_item( $post, $atts ); } $loader = new TemplateLoader(); $layout_template = $loader->locate( $atts['layout'] ); if ( '' === $layout_template ) { $layout_template = $loader->locate( 'grid' ); } $item_template = $loader->locate( 'item' ); Assets::enqueue_front( $atts ); // Video lightbox script: only when this output actually contains one. foreach ( $items as $it ) { if ( ! empty( $it['video'] ) ) { Assets::enqueue_video(); break; } } ++self::$instances; $data = array( 'testimonials' => $items, 'atts' => $atts, 'schema' => (bool) Settings::get( 'enable_schema' ), 'item_template' => $item_template, 'instance' => 'at-' . self::$instances, 'wrapper_class' => self::wrapper_class( $atts ), 'wrapper_style' => self::wrapper_style( $atts ), 'filter_bar' => self::filter_bar_html( $items, $atts ), 'load_more_bar' => self::load_more_html( $items, $atts ), ); $html = $loader->render( $layout_template, $data ); /** * Filter the final testimonial HTML. * * @param string $html Rendered markup. * @param array $atts Display attributes. */ return apply_filters( 'advanced_testimonial_output', $html, $atts ); } /** * Normalize raw attributes into a clean, typed array. * * @param array $atts Raw attributes. * @return array */ public static function normalize( array $atts ) { $atts = wp_parse_args( $atts, self::defaults() ); $atts['layout'] = in_array( $atts['layout'], self::LAYOUTS, true ) ? $atts['layout'] : 'grid'; // Card skin: per-instance value, falling back to the global setting. $skins = self::card_styles(); $style = strtolower( (string) $atts['style'] ); if ( ! isset( $skins[ $style ] ) ) { $style = strtolower( (string) Settings::get( 'card_style', 'classic' ) ); } $atts['style'] = isset( $skins[ $style ] ) ? $style : 'classic'; $atts['title'] = sanitize_text_field( (string) $atts['title'] ); $width = strtolower( (string) $atts['width'] ); $atts['width'] = in_array( $width, array( 'wide', 'full' ), true ) ? $width : ''; $atts['columns'] = (int) $atts['columns']; if ( $atts['columns'] < 1 ) { $atts['columns'] = 1; } if ( $atts['columns'] > 6 ) { $atts['columns'] = 6; } $atts['limit'] = (int) $atts['limit']; if ( 0 === $atts['limit'] ) { $atts['limit'] = -1; } $order = strtolower( (string) $atts['order'] ); $atts['order'] = in_array( $order, array( 'asc', 'desc', 'random' ), true ) ? $order : 'desc'; $orderby = strtolower( (string) $atts['orderby'] ); $atts['orderby'] = in_array( $orderby, array( 'date', 'title', 'menu_order', 'rand', 'rating' ), true ) ? $orderby : 'date'; $atts['group'] = sanitize_text_field( (string) $atts['group'] ); if ( ! empty( $atts['ids'] ) && ! is_array( $atts['ids'] ) ) { $atts['ids'] = array_filter( array_map( 'absint', explode( ',', (string) $atts['ids'] ) ) ); } elseif ( empty( $atts['ids'] ) ) { $atts['ids'] = array(); } foreach ( array( 'show_rating', 'show_image', 'show_company', 'show_designation', 'show_location', 'show_date', 'show_verified', 'show_website', 'show_headline', 'show_video', 'show_filter', 'fade', 'read_more', 'load_more', 'verified' ) as $flag ) { $atts[ $flag ] = self::to_bool( $atts[ $flag ] ); } $atts['autoplay'] = absint( $atts['autoplay'] ); // Marquee scroll speed and card width: a per-instance value overrides the // global setting; an empty/zero value inherits the Styles defaults. $speed_map = array( 'slow' => 25, 'normal' => 45, 'fast' => 75, ); $speed_preset = strtolower( (string) $atts['speed'] ); if ( ! isset( $speed_map[ $speed_preset ] ) ) { $speed_preset = (string) Settings::get( 'marquee_speed', 'normal' ); } $atts['marquee_speed'] = isset( $speed_map[ $speed_preset ] ) ? $speed_map[ $speed_preset ] : 45; $card_width = (int) $atts['card_width']; if ( $card_width <= 0 ) { $card_width = (int) Settings::get( 'marquee_width', 340 ); } $atts['marquee_item'] = max( 200, min( 600, $card_width ) ); $direction = strtolower( (string) $atts['direction'] ); if ( ! in_array( $direction, array( 'left', 'right' ), true ) ) { $direction = strtolower( (string) Settings::get( 'marquee_direction', 'left' ) ); } $atts['direction'] = in_array( $direction, array( 'left', 'right' ), true ) ? $direction : 'left'; // Load more: how many items to show initially / reveal per click. $atts['_lm_initial'] = ! empty( $atts['load_more'] ) ? max( 1, (int) Settings::get( 'load_more_count', 6 ) ) : 0; $atts['cache'] = (bool) Settings::get( 'cache_queries' ) && ! get_option( Tools::DEBUG_OPTION ); return $atts; } /** * Cast a loose value to boolean. * * @param mixed $value Value. * @return bool */ private static function to_bool( $value ) { if ( is_bool( $value ) ) { return $value; } return in_array( strtolower( (string) $value ), array( '1', 'true', 'yes', 'on' ), true ); } /** * What the reviews are about, for itemReviewed markup. * * Google shows star ratings for reviews of products, apps, courses and * similar — never for a business reviewing itself. So the owner picks the * reviewed thing in Settings → General; when they leave the default * ("my own business") and the block renders on a WooCommerce product * page, that product is used instead — the one case the owner never has * to configure. An explicit non-organization choice always wins. * * @return array{type:string,name:string,url:string,image:string} */ public static function reviewed_entity() { static $cached = null; if ( null !== $cached ) { return $cached; } $types = array( 'organization' => 'https://schema.org/Organization', 'product' => 'https://schema.org/Product', 'software' => 'https://schema.org/SoftwareApplication', 'course' => 'https://schema.org/Course', 'event' => 'https://schema.org/Event', ); $type = (string) Settings::get( 'reviewed_type', 'organization' ); if ( ! isset( $types[ $type ] ) ) { $type = 'organization'; } $entity = array( 'type' => $types[ $type ], 'name' => (string) Settings::get( 'reviewed_name', '' ), 'url' => (string) Settings::get( 'reviewed_url', '' ), 'image' => (string) Settings::get( 'reviewed_image', '' ), ); if ( 'organization' === $type ) { if ( '' === $entity['name'] ) { $entity['name'] = (string) get_bloginfo( 'name' ); } if ( function_exists( 'is_product' ) && is_product() ) { $product_id = get_queried_object_id(); if ( $product_id && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $product_id ); if ( $product ) { $entity['type'] = 'https://schema.org/Product'; $entity['name'] = $product->get_name(); if ( function_exists( 'get_permalink' ) ) { $entity['url'] = (string) get_permalink( $product_id ); } $image_id = $product->get_image_id(); if ( $image_id ) { $entity['image'] = (string) wp_get_attachment_url( $image_id ); } } } } } /** * Filter the reviewed entity used in itemReviewed markup. * * @param array $entity Entity with type, name, url, image keys. */ $cached = apply_filters( 'advanced_testimonial_reviewed_entity', $entity ); return $cached; } /** * Build a normalized data array for one testimonial. * * @param \WP_Post $post Post object. * @param array $atts Display attributes. * @return array */ private static function build_item( $post, array $atts ) { $id = $post->ID; $meta = function ( $field ) use ( $id ) { return get_post_meta( $id, Helpers::meta_key( $field ), true ); }; // The stored rating is the only honest one: schema markup must never // carry a rating the customer did not give. The "Default Rating" // setting still fills the visual stars below, display only. $real_rating = Helpers::clamp_rating( $meta( 'rating' ) ); $rating = $real_rating; if ( $rating <= 0 ) { $rating = Helpers::clamp_rating( Settings::get( 'default_rating', 0 ) ); } $socials = array_filter( array( 'twitter' => $meta( 'social_twitter' ), 'linkedin' => $meta( 'social_linkedin' ), 'facebook' => $meta( 'social_facebook' ), ) ); $item = array( 'id' => $id, 'name' => get_the_title( $id ), 'headline' => (string) $meta( 'headline' ), 'review' => $post->post_content, 'rating' => $rating, 'has_real_rating' => $real_rating > 0, 'reviewed' => self::reviewed_entity(), 'company' => (string) $meta( 'company' ), 'designation' => (string) $meta( 'designation' ), 'location' => (string) $meta( 'location' ), 'website' => (string) $meta( 'website' ), 'email' => (string) $meta( 'email' ), 'verified' => '1' === $meta( 'verified' ), 'date' => (string) $meta( 'review_date' ), 'photo' => $atts['show_image'] ? self::avatar_html( $post ) : '', 'logo' => self::logo_html( (int) $meta( 'company_logo' ) ), 'stars' => self::stars_html( $rating ), 'socials' => $socials, 'groups' => self::item_groups( $id ), 'video' => $atts['show_video'] ? self::video_html( $post, (string) $meta( 'video' ) ) : '', ); /** * Filter a single testimonial's prepared data. * * @param array $item Prepared data. * @param \WP_Post $post Post object. * @param array $atts Display attributes. */ return apply_filters( 'advanced_testimonial_item_data', $item, $post, $atts ); } /** * Groups (taxonomy terms) a testimonial belongs to, as slug => name. * * @param int $id Post ID. * @return array */ private static function item_groups( $id ) { $terms = get_the_terms( $id, Taxonomy::TAXONOMY ); $out = array(); if ( is_array( $terms ) ) { foreach ( $terms as $term ) { $out[ $term->slug ] = $term->name; } } return $out; } /** * Build the group-filter tab bar. Returns an empty string unless filtering * is enabled, the layout is a non-slider grid type, and the displayed items * span at least two distinct groups (otherwise there is nothing to filter). * * @param array $items Prepared testimonial items. * @param array $atts Display attributes. * @return string */ private static function filter_bar_html( array $items, array $atts ) { $filterable = array( 'grid', 'list', 'card', 'masonry' ); if ( empty( $atts['show_filter'] ) || ! in_array( $atts['layout'], $filterable, true ) ) { return ''; } $groups = array(); foreach ( $items as $item ) { if ( empty( $item['groups'] ) ) { continue; } foreach ( $item['groups'] as $slug => $name ) { if ( ! isset( $groups[ $slug ] ) ) { $groups[ $slug ] = $name; } } } if ( count( $groups ) < 2 ) { return ''; } $out = '
'; $out .= ''; foreach ( $groups as $slug => $name ) { $out .= ''; } $out .= '
'; return $out; } /** * Build the "Load more" button. Returns an empty string unless load-more is * enabled, the layout is a non-slider grid type, and there are more items * than the initial batch. * * @param array $items Prepared testimonial items. * @param array $atts Display attributes. * @return string */ private static function load_more_html( array $items, array $atts ) { $ok = array( 'grid', 'list', 'card', 'masonry' ); $initial = (int) $atts['_lm_initial']; if ( empty( $atts['load_more'] ) || ! in_array( $atts['layout'], $ok, true ) || $initial < 1 || count( $items ) <= $initial ) { return ''; } return '
'; } /** * Build the client avatar markup (featured image or initial fallback). * * @param \WP_Post $post Post object. * @return string */ private static function avatar_html( $post ) { $size = (string) Settings::get( 'image_size', 'medium' ); $lazy = (bool) Settings::get( 'lazy_load', 1 ); if ( has_post_thumbnail( $post ) ) { return get_the_post_thumbnail( $post, $size, array( 'class' => 'at-avatar__img', 'alt' => esc_attr( get_the_title( $post ) ), 'loading' => $lazy ? 'lazy' : 'eager', ) ); } $initial = function_exists( 'mb_substr' ) ? mb_substr( get_the_title( $post ), 0, 1 ) : substr( get_the_title( $post ), 0, 1 ); return ''; } /** * Build the company logo markup. * * @param int $logo_id Attachment ID. * @return string */ private static function logo_html( $logo_id ) { if ( ! $logo_id ) { return ''; } return wp_get_attachment_image( $logo_id, 'medium', false, array( 'class' => 'at-logo__img', 'loading' => 'lazy', 'alt' => '', ) ); } /** * Build the video thumbnail + play button markup. * * The actual player (iframe /