'',
// Κενό = το όνομα της ιδιότητας.
'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' );
}
}
}