get_option( 'addoshvi_default_layout', 'carousel' ), 'heading' => '', 'subheading' => '', 'ids' => '', 'limit' => 10, 'columns' => 3, 'columns_tablet' => 2, 'columns_mobile' => 1, 'scroll_by' => 1, 'arrows' => 'yes', 'dots' => 'yes', 'autoplay' => 'no', 'autoplay_speed' => 3000, 'pause_on_hover' => 'yes', 'infinite' => 'yes', ), $atts, 'addoshvi_feed' ); return self::render( $atts ); } /** * Shared render function. * * @param array $atts { * layout: carousel|inline, ids: comma list, limit: int, * columns / columns_tablet / columns_mobile: int — carousel layout * only, how many cards are visible at once per breakpoint. * scroll_by: int — how many cards the prev/next arrows move at a time. * arrows: 'yes'|'no' — show prev/next nav arrows on the carousel. * } * @return string HTML */ public static function render( $atts = array() ) { $defaults = array( 'layout' => get_option( 'addoshvi_default_layout', 'carousel' ), 'heading' => '', 'subheading' => '', 'ids' => '', 'limit' => 10, 'columns' => 3, 'columns_tablet' => 2, 'columns_mobile' => 1, 'scroll_by' => 1, 'arrows' => 'yes', 'dots' => 'yes', 'autoplay' => 'no', 'autoplay_speed' => 3000, 'pause_on_hover' => 'yes', 'infinite' => 'yes', ); $atts = wp_parse_args( $atts, $defaults ); $layout = in_array( $atts['layout'], array( 'carousel', 'inline' ), true ) ? $atts['layout'] : 'carousel'; $atts = self::sanitize_carousel_atts( $atts ); $query_args = array( 'post_type' => ADDOSHVI_POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => absint( $atts['limit'] ) > 0 ? absint( $atts['limit'] ) : 10, 'orderby' => 'menu_order date', 'order' => 'DESC', ); // Deliberately '' !== ... rather than !empty(...): PHP's empty('0') // is true, so an explicit ids="0" (used by callers that want "match // nothing" — e.g. the Elementor widget before anything's synced yet) // would silently fall through to "no ids filter at all" and show the // latest N videos site-wide instead of correctly showing none. if ( '' !== $atts['ids'] ) { $ids = array_map( 'absint', explode( ',', $atts['ids'] ) ); $query_args['post__in'] = $ids; $query_args['orderby'] = 'post__in'; unset( $query_args['posts_per_page'] ); $query_args['posts_per_page'] = count( $ids ); } $query = new WP_Query( $query_args ); if ( ! $query->have_posts() ) { if ( current_user_can( 'edit_posts' ) ) { return '

' . esc_html__( 'No shoppable videos yet. Add one under Shoppable Videos → Add New.', 'addonn-shoppable-video' ) . '

'; } return ''; } $videos = array(); foreach ( $query->posts as $post ) { $video_data = self::get_video_data( $post->ID ); if ( $video_data ) { $videos[] = $video_data; } } wp_reset_postdata(); if ( empty( $videos ) ) { return ''; } wp_enqueue_style( 'addoshvi-frontend' ); wp_enqueue_script( 'addoshvi-frontend' ); ob_start(); self::render_heading_block( $atts ); $template = ADDOSHVI_PLUGIN_DIR . 'templates/' . $layout . '.php'; if ( file_exists( $template ) ) { include $template; } return ob_get_clean(); } /** * Optional heading + subheading printed above the feed. Shared by * render() and render_curated_preview() so the shortcode, Elementor * widget (including its live "curate" preview), and Gutenberg block all * show it identically. Prints nothing if both are empty — adding this * never changes existing widgets/shortcodes that don't set them. */ private static function render_heading_block( $atts ) { $heading = isset( $atts['heading'] ) ? trim( (string) $atts['heading'] ) : ''; $subheading = isset( $atts['subheading'] ) ? trim( (string) $atts['subheading'] ) : ''; if ( '' === $heading && '' === $subheading ) { return; } echo '
'; if ( '' !== $heading ) { echo '

' . esc_html( $heading ) . '

'; } if ( '' !== $subheading ) { echo '

' . esc_html( $subheading ) . '

'; } echo '
'; } /** * Shared sanitizer for the carousel display settings (columns per * breakpoint, scroll-by, arrows, dots, autoplay/speed/loop) — used by * both render() and render_curated_preview() so the two stay in sync. */ private static function sanitize_carousel_atts( $atts ) { $atts['columns'] = max( 1, min( 8, absint( $atts['columns'] ) ) ); $atts['columns_tablet'] = max( 1, min( 8, absint( $atts['columns_tablet'] ) ) ); $atts['columns_mobile'] = max( 1, min( 8, absint( $atts['columns_mobile'] ) ) ); $atts['scroll_by'] = max( 1, absint( $atts['scroll_by'] ) ); // Note: an explicit '' (Elementor SWITCHER "off" value) must resolve // to 'no' here, not fall back to a default — only a genuinely // *missing* key should use the default, and that's already handled // by the isset() checks below before to_yes_no() ever sees the value. $atts['arrows'] = self::to_yes_no( isset( $atts['arrows'] ) ? $atts['arrows'] : 'yes' ); $atts['dots'] = self::to_yes_no( isset( $atts['dots'] ) ? $atts['dots'] : 'yes' ); $atts['autoplay'] = self::to_yes_no( isset( $atts['autoplay'] ) ? $atts['autoplay'] : 'no' ); $atts['pause_on_hover'] = self::to_yes_no( isset( $atts['pause_on_hover'] ) ? $atts['pause_on_hover'] : 'yes' ); $atts['infinite'] = self::to_yes_no( isset( $atts['infinite'] ) ? $atts['infinite'] : 'yes' ); $atts['autoplay_speed'] = isset( $atts['autoplay_speed'] ) ? absint( $atts['autoplay_speed'] ) : 3000; if ( $atts['autoplay_speed'] < 1000 ) { $atts['autoplay_speed'] = 1000; // Guard against a too-fast/zero interval. } return $atts; } /** * Normalize a loosely-typed "on/off" value (Elementor switchers send * 'yes'/'', Gutenberg sends booleans, shortcode atts send strings) to a * plain 'yes'/'no' string. An empty string always means "no" (switcher * off) — it is never treated as "missing". */ private static function to_yes_no( $value ) { return in_array( $value, array( 'yes', true, 'true', '1', 1 ), true ) ? 'yes' : 'no'; } /** * Elementor editor-only live preview for the "curate" video-select mode * — renders straight from the repeater's unsaved settings (video/cover * attachment IDs, tagged product IDs) instead of querying the addoshvi_video * CPT, since those posts are only created/updated when the page is * actually saved (see ADDOSHVI_Elementor_Sync). Lets the admin see the real * card layout while still mid-edit, before hitting Update. * * @param array $items Elementor repeater rows (widget 'video_items' setting). * @param array $atts Same shape as render() — layout/columns/etc. * @return string HTML */ public static function render_curated_preview( $items, $atts = array() ) { $defaults = array( 'layout' => 'carousel', 'heading' => '', 'subheading' => '', 'columns' => 3, 'columns_tablet' => 2, 'columns_mobile' => 1, 'scroll_by' => 1, 'arrows' => 'yes', 'dots' => 'yes', 'autoplay' => 'no', 'autoplay_speed' => 3000, 'pause_on_hover' => 'yes', 'infinite' => 'yes', ); $atts = self::sanitize_carousel_atts( wp_parse_args( $atts, $defaults ) ); $layout = in_array( $atts['layout'], array( 'carousel', 'inline' ), true ) ? $atts['layout'] : 'carousel'; $videos = array(); foreach ( (array) $items as $item ) { if ( empty( $item['video']['id'] ) ) { continue; } $video_attachment_id = absint( $item['video']['id'] ); $video_url = wp_get_attachment_url( $video_attachment_id ); if ( ! $video_url ) { continue; } $thumb_url = ! empty( $item['cover']['id'] ) ? wp_get_attachment_image_url( absint( $item['cover']['id'] ), 'large' ) : ''; $product_ids = ! empty( $item['products'] ) ? array_map( 'absint', (array) $item['products'] ) : array(); $products = array(); foreach ( $product_ids as $pid ) { $product = wc_get_product( $pid ); if ( ! $product || ! $product->is_purchasable() ) { continue; } $products[] = array( 'id' => $product->get_id(), 'name' => $product->get_name(), 'price_html' => $product->get_price_html(), 'price' => $product->get_price(), 'image' => wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' ), 'permalink' => get_permalink( $product->get_id() ), 'in_stock' => $product->is_in_stock(), 'has_options' => $product->is_type( 'variable' ), ); } if ( empty( $products ) ) { continue; // Same "must have a purchasable product" rule as get_video_data(). } $whatsapp_enabled = ! empty( $item['whatsapp_enabled'] ) && 'yes' === $item['whatsapp_enabled']; $whatsapp_number = ! empty( $item['whatsapp_number'] ) ? $item['whatsapp_number'] : get_option( 'addoshvi_default_whatsapp_number', '' ); $videos[] = array( 'id' => 0, // Preview only — not a real post until the page is saved. 'title' => ! empty( $item['item_title'] ) ? $item['item_title'] : '', 'video_url' => $video_url, 'thumb_url' => $thumb_url, 'products' => $products, 'whatsapp_enabled' => $whatsapp_enabled && ! empty( $whatsapp_number ), 'whatsapp_number' => $whatsapp_number, ); } if ( empty( $videos ) ) { return '

' . esc_html__( 'Add a video and tag at least one product above to see a live preview here.', 'addonn-shoppable-video' ) . '

'; } wp_enqueue_style( 'addoshvi-frontend' ); wp_enqueue_script( 'addoshvi-frontend' ); ob_start(); self::render_heading_block( $atts ); $template = ADDOSHVI_PLUGIN_DIR . 'templates/' . $layout . '.php'; if ( file_exists( $template ) ) { include $template; } return ob_get_clean(); } private static $modal_output = false; private static $editor_modal_output = false; /** * Print the shared product/cart modal markup once per page, no matter * how many feeds (shortcode/Elementor/Gutenberg instances) are on it. */ public static function ensure_modal_output() { if ( self::$modal_output ) { return; } self::$modal_output = true; include ADDOSHVI_PLUGIN_DIR . 'templates/product-modal.php'; } /** * Print the shared "Add Video" modal once per page (staff-only — the * caller already checked ADDOSHVI_Frontend_Editor::current_user_can_edit() * before including add-video-trigger.php, which calls this). */ public static function ensure_editor_modal_output() { if ( self::$editor_modal_output ) { return; } self::$editor_modal_output = true; include ADDOSHVI_PLUGIN_DIR . 'templates/add-video-modal.php'; } /** * Build a display-ready array for one video post. */ public static function get_video_data( $post_id ) { $video_attachment_id = get_post_meta( $post_id, '_addoshvi_video_attachment_id', true ); if ( ! $video_attachment_id ) { return null; } $video_url = wp_get_attachment_url( $video_attachment_id ); if ( ! $video_url ) { return null; } $thumb_url = get_the_post_thumbnail_url( $post_id, 'large' ); $product_ids = get_post_meta( $post_id, '_addoshvi_products', true ); $product_ids = is_array( $product_ids ) ? $product_ids : array(); $products = array(); foreach ( $product_ids as $pid ) { $product = wc_get_product( $pid ); if ( ! $product || ! $product->is_purchasable() ) { continue; } $products[] = array( 'id' => $product->get_id(), 'name' => $product->get_name(), 'price_html' => $product->get_price_html(), 'price' => $product->get_price(), 'image' => wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' ), 'permalink' => get_permalink( $product->get_id() ), 'in_stock' => $product->is_in_stock(), 'has_options' => $product->is_type( 'variable' ), ); } if ( empty( $products ) ) { return null; // A video with no purchasable products isn't shoppable. } $whatsapp_enabled = get_post_meta( $post_id, '_addoshvi_whatsapp_enabled', true ) === '1'; $whatsapp_number = get_post_meta( $post_id, '_addoshvi_whatsapp_number', true ); if ( ! $whatsapp_number ) { $whatsapp_number = get_option( 'addoshvi_default_whatsapp_number', '' ); } return array( 'id' => $post_id, 'title' => get_the_title( $post_id ), 'video_url' => $video_url, 'thumb_url' => $thumb_url, 'products' => $products, 'whatsapp_enabled' => $whatsapp_enabled && ! empty( $whatsapp_number ), 'whatsapp_number' => $whatsapp_number, ); } }