'', // Κενό = το όνομα της ιδιότητας. 'title' => '', // checkbox | radio | select | buttons (το price πάντα εύρος) 'style' => 'checkbox', 'limit' => 12, // Εμφάνιση πλήθους δίπλα σε κάθε τιμή. 'counts' => 'yes', // 🔴 Το widget τυπώνει ΔΙΚΟ του τίτλο (με το markup του // θέματος) — αλλιώς ο τίτλος θα έβγαινε δύο φορές. 'heading' => 'yes', // Πεδίο αναζήτησης μέσα στο φίλτρο — για ιδιότητες με δεκάδες // τιμές («Μάρκα», «Γεύση»), όπου η λίστα είναι αδιάβαστη. 'search' => 'no', /* * ΠΩΣ συνδυάζονται ΠΟΛΛΕΣ επιλογές ΤΟΥ ΙΔΙΟΥ φίλτρου: * any (προεπιλογή) «κόκκινο Ή μπλε» * all «αδιάβροχο ΚΑΙ αντιανεμικό» * * 🔴 ΔΕΝ αφορά διαφορετικά φίλτρα μεταξύ τους — αυτά είναι * ΠΑΝΤΑ AND («κόκκινο ΚΑΙ 39»), αλλιώς το φιλτράρισμα δεν * στενεύει ποτέ. */ 'match' => 'any', /* * 🔴 Αυτο-απόκρυψη εκεί που το φίλτρο δεν έχει νόημα. * * Το widget/block ζει στο sidebar, και το sidebar το δείχνει * το θέμα ΠΑΝΤΟΥ — και στη σελίδα προϊόντος, στο καλάθι, στο * ταμείο. Εκεί δεν υπάρχει λίστα να φιλτράρεις· χειρότερα, η * μόνη λίστα που υπάρχει είναι τα «σχετικά προϊόντα», και θα * τα αντικαθιστούσαμε. Το shortcode ΔΕΝ κρύβεται μόνο του — * αν το έγραψε κάποιος σε σελίδα, το εννοεί. */ 'auto_hide' => 'no', // Δικές του κλάσεις, ώστε ο καταστηματάρχης να το στήσει με // δικό του CSS χωρίς να κυνηγά selectors. 'class' => '', 'category' => '', 'ajax' => '', // Ετικέτες ενεργών φίλτρων («Woody Toys ✕») πάνω από το // πλέγμα. Προεπιλογή ΟΧΙ — το «✕ Καθαρισμός» υπάρχει πάντα. 'active_filters' => 'no', /* * ΜΟΝΟ για attribute="categories" (0.6.5): * category_display list (προεπιλογή, επίπεδη λίστα) | tree (ιεραρχία) * category_depth all (προεπιλογή) | top (μόνο το πρώτο επίπεδο) * | children_of_current (μόνο οι υποκατηγορίες * της τρέχουσας κατηγορίας) * Προεπιλογές list/all = ΑΚΡΙΒΩΣ η παλιά συμπεριφορά (ονόματα). */ 'category_display' => 'list', 'category_depth' => 'all', ), (array) $atts, 'addsynapse_search_filter' ); // 🔴 Αποκωδικοποίηση ΜΙΑ φορά, στην πηγή: ετικέτα/τίτλος μπορεί να // έχουν αποθηκευτεί ως «Kettle & Crow» (όνομα όρου WP, παλιό // block). Το esc_attr() πιο κάτω κωδικοποιεί ξανά — αλλιώς φαινόταν // «&» ΚΑΙ η ετικέτα δεν ταίριαζε με τον (αποκωδικοποιημένο) δείκτη. $attribute = trim( Addsynapse_Search_Indexer::decode_text( (string) $atts['attribute'] ) ); $atts['title'] = Addsynapse_Search_Indexer::decode_text( (string) $atts['title'] ); if ( '' === $attribute ) { return ''; } if ( self::flag( $atts['auto_hide'], false ) && self::pointless_here() ) { return ''; } // Ιδιότητα -> το κοινό facet· ειδικό πεδίο -> ο εαυτός του. $special = in_array( $attribute, self::SPECIAL, true ); $field = $special ? $attribute : 'attribute_pairs'; $label = $special ? '' : $attribute; $style = in_array( $atts['style'], array( 'checkbox', 'radio', 'select', 'buttons' ), true ) ? $atts['style'] : 'checkbox'; self::enqueue(); // Ιεραρχία κατηγοριών: μόνο για το φίλτρο κατηγορίας, και ο χάρτης // id → όνομα/γονέας φεύγει στη σελίδα ΜΟΝΟ όταν κάποιο κουτί τον θέλει. $cat_attrs = ''; if ( 'categories' === $attribute ) { $display = self::category_display( $atts['category_display'] ); $depth = self::category_depth( $atts['category_depth'] ); if ( 'list' !== $display || 'all' !== $depth ) { $cat_attrs = sprintf( ' data-cat-display="%s" data-cat-depth="%s"', esc_attr( $display ), esc_attr( $depth ) ); self::enqueue_cat_terms(); } } return sprintf( '
', esc_attr( self::classes( $atts['class'] ) ), esc_attr( $field ), esc_attr( $label ), esc_attr( self::flag( $atts['heading'], true ) ? self::title( $atts['title'], $attribute ) : '' ), esc_attr( $style ), absint( $atts['limit'] ), esc_attr( self::flag( $atts['counts'], true ) ? '1' : '0' ), esc_attr( self::flag( $atts['search'], false ) ? '1' : '0' ), esc_attr( 'all' === $atts['match'] ? 'all' : 'any' ), esc_attr( self::category( $atts['category'] ) ), esc_attr( self::category_id( $atts['category'] ) ), esc_attr( self::ajax( $atts['ajax'] ) ? '1' : '0' ), esc_attr( self::flag( $atts['active_filters'], false ) ? '1' : '0' ), $cat_attrs // Ήδη esc_attr() παραπάνω. ); } // ------------------------------------------------------------------ // [addsynapse_search_products] — το πλέγμα. // ------------------------------------------------------------------ public static function render_products( $atts ) { if ( ! self::active() ) { return ''; } $atts = shortcode_atts( array( 'per_page' => 24, 'layout' => 'grid', // relevance | price_asc | price_desc (το price είναι sortable) 'sort' => '', 'category' => '', 'ajax' => '', 'class' => '', 'active_filters' => 'no', ), (array) $atts, 'addsynapse_search_products' ); self::enqueue(); return sprintf( '
', esc_attr( self::classes( $atts['class'] ) ), absint( $atts['per_page'] ), esc_attr( 'list' === $atts['layout'] ? 'list' : 'grid' ), esc_attr( in_array( $atts['sort'], array( 'price_asc', 'price_desc' ), true ) ? $atts['sort'] : '' ), esc_attr( self::category( $atts['category'] ) ), esc_attr( self::category_id( $atts['category'] ) ), esc_attr( self::ajax( $atts['ajax'] ) ? '1' : '0' ), esc_attr( self::flag( $atts['active_filters'], false ) ? '1' : '0' ) ); } // ------------------------------------------------------------------ // [addsynapse_search_filters] — όλα μαζί, από τις ρυθμίσεις. // ------------------------------------------------------------------ public static function render_all( $atts ) { if ( ! self::active() ) { return ''; } $atts = shortcode_atts( array( 'per_page' => 24, 'layout' => 'grid', 'sort' => '', 'category' => '', 'ajax' => '', // Λίστα με κόμματα· η ΣΕΙΡΑ που γράφεται είναι η σειρά // εμφάνισης. Κενό = ό,τι λένε οι ρυθμίσεις. 'attributes' => '', 'style' => 'checkbox', // filters | products | both 'show' => 'both', 'active_filters' => 'no', // Για το φίλτρο κατηγορίας (βλ. render_filter()). 'category_display' => 'list', 'category_depth' => 'all', ), (array) $atts, 'addsynapse_search_filters' ); $show = in_array( $atts['show'], array( 'filters', 'products', 'both' ), true ) ? $atts['show'] : 'both'; $common = sprintf( ' category="%s" ajax="%s" active_filters="%s"', esc_attr( $atts['category'] ), esc_attr( $atts['ajax'] ), self::flag( $atts['active_filters'], false ) ? 'yes' : 'no' ); $out = ''; if ( 'products' !== $show ) { // Κατηγορία, μάρκα, τιμή και ΟΛΕΣ οι ιδιότητες που βρήκε η // δεικτοδότηση. Ποιες ακριβώς και πώς εμφανίζονται ρυθμίζεται // ανά widget — εδώ θέλουμε «όλα, με μία γραμμή». $fields = array_merge( array( 'categories', 'brand', 'price' ), self::enabled_attributes( $atts['attributes'] ) ); $limit = absint( Addsynapse_Search_Settings::get( 'filters_max_values', 12 ) ); foreach ( $fields as $field ) { $out .= self::render_filter( array( 'attribute' => $field, 'style' => $atts['style'], 'limit' => $limit, 'category' => $atts['category'], 'ajax' => $atts['ajax'], 'category_display' => $atts['category_display'], 'category_depth' => $atts['category_depth'], ) ); } $out = '
' . $out . '
'; } if ( 'filters' !== $show ) { $out .= do_shortcode( sprintf( '[addsynapse_search_products per_page="%d" layout="%s" sort="%s"%s]', absint( $atts['per_page'] ), esc_attr( $atts['layout'] ), esc_attr( $atts['sort'] ), $common ) ); } return 'both' === $show ? '
' . $out . '
' : $out; } // ------------------------------------------------------------------ // Βοηθητικά — τα χρησιμοποιούν ΚΑΙ το widget ΚΑΙ το block. // ------------------------------------------------------------------ /** * Όλα όσα μπορεί να διαλέξει ο χειριστής, ως value => ετικέτα. * * @return array */ public static function choices() { $out = array( 'categories' => __( 'Category', 'addsynapse-search-filter' ), 'brand' => __( 'Brand', 'addsynapse-search-filter' ), 'price' => __( 'Price (range)', 'addsynapse-search-filter' ), ); foreach ( self::attribute_labels() as $label ) { $out[ $label ] = $label; } return $out; } /** Οι τρόποι εμφάνισης, ως value => ετικέτα. */ public static function styles() { return array( 'checkbox' => __( 'Checkboxes (multiple choice)', 'addsynapse-search-filter' ), 'radio' => __( 'Radio buttons (one choice)', 'addsynapse-search-filter' ), 'select' => __( 'Dropdown', 'addsynapse-search-filter' ), 'buttons' => __( 'Buttons', 'addsynapse-search-filter' ), ); } /** Εμφάνιση φίλτρου κατηγορίας, ως value => ετικέτα. */ public static function category_displays() { return array( 'list' => __( 'Flat list', 'addsynapse-search-filter' ), 'tree' => __( 'Tree (subcategories under their parent)', 'addsynapse-search-filter' ), ); } /** Βάθος φίλτρου κατηγορίας, ως value => ετικέτα. */ public static function category_depths() { return array( 'all' => __( 'All levels', 'addsynapse-search-filter' ), 'top' => __( 'Top level only', 'addsynapse-search-filter' ), 'children_of_current' => __( 'Only subcategories of the current category', 'addsynapse-search-filter' ), ); } /** @return string list|tree */ public static function category_display( $value ) { $value = sanitize_key( (string) $value ); return array_key_exists( $value, self::category_displays() ) ? $value : 'list'; } /** @return string all|top|children_of_current (δεκτό και το σύντομο «children») */ public static function category_depth( $value ) { $value = sanitize_key( (string) $value ); if ( 'children' === $value ) { $value = 'children_of_current'; } return array_key_exists( $value, self::category_depths() ) ? $value : 'all'; } /** * Χάρτης κατηγοριών για το δέντρο: term_id => [όνομα, term_id γονέα]. * * 🔴 Τα ΠΛΗΘΗ δεν έρχονται από εδώ — έρχονται από το facet * `category_path_ids` της ίδιας απάντησης (ο γονέας μετρά και τα προϊόντα * των απογόνων του). Εδώ μόνο ονόματα + ιεραρχία, που ο δείκτης δεν έχει. * Συμπαγής μορφή (λίστα, όχι αντικείμενο) για μικρό payload· cached 12 * ώρες ανά locale (WPML/Polylang επιστρέφουν άλλα ονόματα ανά γλώσσα). * * @return array */ public static function cat_terms() { $locale = function_exists( 'determine_locale' ) ? determine_locale() : get_locale(); $cached = get_transient( self::CAT_TERMS_CACHE ); if ( is_array( $cached ) && isset( $cached[ $locale ] ) && is_array( $cached[ $locale ] ) ) { return $cached[ $locale ]; } $terms = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false, 'orderby' => 'count', 'order' => 'DESC', 'number' => self::CAT_TERMS_MAX, 'update_term_meta_cache' => false, ) ); $map = array(); if ( is_array( $terms ) ) { foreach ( $terms as $term ) { if ( $term instanceof WP_Term ) { $map[ (int) $term->term_id ] = array( Addsynapse_Search_Indexer::decode_text( $term->name ), (int) $term->parent, ); } } } $cached = is_array( $cached ) ? $cached : array(); $cached[ $locale ] = $map; set_transient( self::CAT_TERMS_CACHE, $cached, 12 * HOUR_IN_SECONDS ); return $map; } /** Ο χάρτης κατηγοριών στη σελίδα — ΜΙΑ φορά, μόνο αν κάποιο κουτί τον ζητά. */ private static function enqueue_cat_terms() { static $done = false; if ( $done ) { return; } $done = true; // Ως αντικείμενο (όχι λίστα) ακόμα κι αν είναι κενό: το JS κάνει lookup ανά id. $map = self::cat_terms(); wp_add_inline_script( self::HANDLE, 'window.addsynapseSearchFiltersConfig = window.addsynapseSearchFiltersConfig || {};' . 'window.addsynapseSearchFiltersConfig.catTerms = ' . wp_json_encode( $map ? $map : new stdClass() ) . ';', 'before' ); } /** * Οι αναγνώσιμες ετικέτες των ιδιοτήτων του καταστήματος («Χρώμα», * «Νούμερο»). Οι τιμές έρχονται από το facet `attribute_pairs` * («Ιδιότητα:Τιμή») — εδώ χρειαζόμαστε μόνο τα ονόματα των ομάδων. * * @return string[] */ public static function attribute_labels() { // Πρώτη πηγή: ό,τι έχει δει η δεικτοδότηση — καλύπτει ΚΑΙ τις custom // ιδιότητες, που είναι η συνηθισμένη περίπτωση στα μικρά καταστήματα. $labels = class_exists( 'Addsynapse_Search_Indexer' ) ? Addsynapse_Search_Indexer::attribute_labels() : array(); // Δεύτερη: οι ταξινομικές ιδιότητες του WooCommerce, για καταστήματα // που δεν έχουν δεικτοδοτήσει ακόμα. if ( function_exists( 'wc_get_attribute_taxonomies' ) ) { foreach ( (array) wc_get_attribute_taxonomies() as $tax ) { if ( ! empty( $tax->attribute_label ) ) { $labels[] = Addsynapse_Search_Indexer::decode_text( $tax->attribute_label ); } } } /** * Φίλτρο: ποιες ιδιότητες προσφέρονται ως φίλτρα. * * Χρήσιμο για καταστήματα με custom (μη-ταξινομικά) attributes, που * δεν εμφανίζονται στο wc_get_attribute_taxonomies(). * * @param string[] $labels */ return array_values( array_unique( (array) apply_filters( 'addsynapse_search_filter_attributes', $labels ) ) ); } /** Κλάσεις χειριστή, καθαρισμένες μία-μία (επιτρέπονται πολλές). */ private static function classes( $raw ) { $out = array(); foreach ( preg_split( '/\s+/', (string) $raw ) as $class ) { $class = sanitize_html_class( $class ); if ( '' !== $class ) { $out[] = $class; } } return $out ? ' ' . implode( ' ', $out ) : ''; } /** * Σελίδες όπου ένα φίλτρο προϊόντων δεν έχει τι να φιλτράρει. * * @return bool */ private static function pointless_here() { foreach ( array( 'is_product', 'is_cart', 'is_checkout', 'is_account_page' ) as $check ) { if ( function_exists( $check ) && call_user_func( $check ) ) { return true; } } return is_singular() && ! is_page(); } private static function active() { if ( ! class_exists( 'Addsynapse_Search_License' ) || ! Addsynapse_Search_License::is_active() ) { return false; } return ! self::paused_by_coverage(); } /** * 🔴 Μερική κάλυψη καταλόγου (0.8.0): το πλέγμα των φίλτρων βλέπει ΜΟΝΟ τον * δείκτη — σε κατάλογο μεγαλύτερο από το πακέτο θα έκρυβε προϊόντα από τις * σελίδες κατηγοριών. Τότε τα φίλτρα δεν εμφανίζονται και μένει το πλέγμα του * θέματος (όλα τα προϊόντα). Φίλτρο `addsynapse_search_filters_on_partial_coverage` * (true) για όποιον προτιμά φίλτρα πάνω στα καλυμμένα προϊόντα. * * @return bool */ public static function paused_by_coverage() { if ( ! class_exists( 'Addsynapse_Search_Coverage' ) || ! Addsynapse_Search_Coverage::is_partial() ) { return false; } /** * Filters whether the product filters stay on when the plan covers only part of the catalogue. * * @param bool $keep Default false: filters pause and the theme's own product grid (all products) is shown. */ return ! apply_filters( 'addsynapse_search_filters_on_partial_coverage', false ); } private static function title( $title, $attribute ) { if ( '' !== trim( (string) $title ) ) { return $title; } $choices = self::choices(); return isset( $choices[ $attribute ] ) ? $choices[ $attribute ] : $attribute; } /** Ρητό yes/no υπερισχύει της ρύθμισης· κενό = η ρύθμιση. */ private static function ajax( $value ) { return '' === $value ? 'reload' !== Addsynapse_Search_Settings::get( 'filters_mode', 'ajax' ) : self::flag( $value, true ); } private static function flag( $value, $default ) { if ( '' === $value || null === $value ) { return $default; } return in_array( strtolower( (string) $value ), array( 'yes', '1', 'true', 'on' ), true ); } /** * Σε αρχείο κατηγορίας προϊόντων το πλαίσιο βγαίνει μόνο του — ο * καταστηματάρχης βάζει το widget μία φορά και δουλεύει σε κάθε κατηγορία. */ private static function category( $category ) { $category = trim( (string) $category ); if ( '' !== $category ) { return $category; } if ( function_exists( 'is_product_category' ) && is_product_category() ) { $term = get_queried_object(); if ( $term instanceof WP_Term ) { // 🔴 Καθαρό όνομα, ΙΔΙΟ με ό,τι έχει ο δείκτης (βλ. // Addsynapse_Search_Indexer::decode_text()) — το WP το κρατά ως // «A & B» και το φίλτρο `categories = "..."` δεν θα ταίριαζε. return Addsynapse_Search_Indexer::decode_text( $term->name ); } } return ''; } /** * Το term_id της κατηγορίας-πλαισίου (0 = άγνωστο → το JS μένει στο όνομα). * * 🔴 Σε αρχείο κατηγορίας: ο τρέχων όρος. Το JS φιλτράρει τότε με * `category_path_ids = ` (άμεσες + πρόγονοι, βλ. indexer), ώστε ο * γονέας να δείχνει και τα προϊόντα που είναι ΜΟΝΟ σε υποκατηγορία, και * δύο ομώνυμες κατηγορίες σε άλλους κλάδους να μην ανακατεύονται. * Ρητό `category="..."`: ID μόνο αν το όνομα/slug δείχνει σε ΕΝΑΝ όρο — * αλλιώς το όνομα, όπως πριν (δεν μαντεύουμε κλάδο). * * @param string $category Όνομα/slug από shortcode/widget ή ''. * @return int */ private static function category_id( $category ) { $category = trim( (string) $category ); if ( '' === $category ) { if ( function_exists( 'is_product_category' ) && is_product_category() ) { $term = get_queried_object(); if ( $term instanceof WP_Term ) { return (int) $term->term_id; } } return 0; } $by_slug = get_term_by( 'slug', sanitize_title( $category ), 'product_cat' ); if ( $by_slug instanceof WP_Term && Addsynapse_Search_Indexer::decode_text( $by_slug->name ) === $category ) { return (int) $by_slug->term_id; } $terms = get_terms( array( 'taxonomy' => 'product_cat', 'name' => $category, 'hide_empty' => false, 'fields' => 'ids', 'number' => 2, ) ); return is_array( $terms ) && 1 === count( $terms ) ? (int) $terms[0] : 0; } /** * Ποιες ομάδες ιδιοτήτων εμφανίζει το σύνθετο [addsynapse_search_filters]. * * Κενό override = ΟΛΕΣ όσες βρέθηκαν· αλλιώς η λίστα του χειριστή, ΜΕ ΤΗ * ΣΕΙΡΑ που τη γράφει — όχι με τη σειρά που τις ανακάλυψε η δεικτοδότηση. * * @param string $override Λίστα με κόμματα. * @return string[] */ private static function enabled_attributes( $override = '' ) { $known = self::attribute_labels(); if ( '' === trim( (string) $override ) ) { return $known; } $wanted = array_filter( array_map( 'trim', explode( ',', (string) $override ) ) ); return array_values( array_intersect( $wanted, $known ) ); } private static function enqueue() { // Το config (endpoint, token, index, νόμισμα) το περνάει ήδη το // Addsynapse_Search_Frontend μέσω wp_localize_script στο handle 'addsynapse-search'. // Το ζητάμε ρητά ώστε να υπάρχει window.addsynapseSearchConfig ακόμα κι αν ο // καταστηματάρχης έχει κρύψει το πεδίο αναζήτησης. if ( class_exists( 'Addsynapse_Search_Frontend' ) ) { Addsynapse_Search_Frontend::maybe_enqueue(); } wp_enqueue_style( self::HANDLE, plugins_url( 'assets/css/addsynapse-search-filters.css', ADDSYNAPSE_SEARCH_PLUGIN_FILE ), array(), addsynapse_search_asset_version( 'assets/css/addsynapse-search-filters.css' ) ); // Εξάρτηση από 'addsynapse-search': από εκεί έρχονται το config ΚΑΙ τα // κοινά βοηθητικά (window.addsynapseSearchShared: URL validation, τιμή, // ανανέωση token). Χωρίς αυτήν η σειρά φόρτωσης είναι τύχη. wp_enqueue_script( self::HANDLE, plugins_url( 'assets/js/addsynapse-search-filters.js', ADDSYNAPSE_SEARCH_PLUGIN_FILE ), array( 'addsynapse-search' ), addsynapse_search_asset_version( 'assets/js/addsynapse-search-filters.js' ), true ); // Με ακριβή αναζήτηση παραλλαγών οι επιλογές των φίλτρων διαβάζονται // από το `facet_pairs` (βλ. Addsynapse_Search_Indexer::add_facet_pairs() // και FACET_PAIRS στο JS). Ξεχωριστό global ώστε να μην αγγίζουμε το // κοινό config της αναζήτησης. // // hideOos: τα πλέγματα καταλόγου κρύβουν τα εξαντλημένα αν το ζητά Ή η // δική μας ρύθμιση (oos_behavior = hide, διαβάζεται στο JS από το κοινό // config) Ή το WooCommerce («Hide out of stock items from the catalog»). // termId: ο όρος του τρέχοντος αρχείου κατηγορίας (βλ. category_id()). // // 🔴 ΜΙΑ φορά ανά σελίδα: το enqueue() τρέχει για κάθε widget. static $done = false; if ( ! $done ) { $done = true; $fcfg = array( 'facetPairs' => (bool) Addsynapse_Search_Settings::get( 'variation_indexing', false ), 'hideOos' => 'yes' === get_option( 'woocommerce_hide_out_of_stock_items', 'no' ), 'termId' => self::category_id( '' ), ); wp_add_inline_script( self::HANDLE, 'window.addsynapseSearchFiltersConfig = ' . wp_json_encode( $fcfg ) . ';', 'before' ); } } }