From d71d8c85baac9261a0b6426f01ad1236a6ea49c4 Mon Sep 17 00:00:00 2001 From: "Hanson.xyz Dev" Date: Fri, 28 Nov 2025 16:37:18 -0600 Subject: [PATCH] Step 2.6: Implement AJAX property filtering with URL state management --- db-snapshots/db-snapshot.sql | 2 +- .../themes/homeproz/dist/assets/main.js | 2 +- wp-content/themes/homeproz/functions.php | 3 + .../themes/homeproz/inc/ajax-handlers.php | 231 +++++++++++++++++ wp-content/themes/homeproz/src/main.js | 1 + .../property/property-filters.js | 244 ++++++++++++++++++ 6 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 wp-content/themes/homeproz/inc/ajax-handlers.php create mode 100644 wp-content/themes/homeproz/template-parts/property/property-filters.js diff --git a/db-snapshots/db-snapshot.sql b/db-snapshots/db-snapshot.sql index 34d3f647..096a4fbd 100644 --- a/db-snapshots/db-snapshot.sql +++ b/db-snapshots/db-snapshot.sql @@ -407,4 +407,4 @@ UNLOCK TABLES; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2025-11-28 16:36:00 +-- Dump completed on 2025-11-28 16:37:18 diff --git a/wp-content/themes/homeproz/dist/assets/main.js b/wp-content/themes/homeproz/dist/assets/main.js index 036b2f15..1a292102 100644 --- a/wp-content/themes/homeproz/dist/assets/main.js +++ b/wp-content/themes/homeproz/dist/assets/main.js @@ -1 +1 @@ -(function(e){var a=e(".menu-toggle"),o=e(".mobile-navigation");a.length&&(a.on("click",function(){var n=e(this).attr("aria-expanded")==="true";e(this).attr("aria-expanded",!n),o.toggleClass("is-open"),n?e("body").removeClass("mobile-menu-open"):e("body").addClass("mobile-menu-open")}),e(document).on("keydown",function(n){n.key==="Escape"&&o.hasClass("is-open")&&(a.attr("aria-expanded","false"),o.removeClass("is-open"),e("body").removeClass("mobile-menu-open"))}),e(document).on("click",function(n){o.hasClass("is-open")&&!e(n.target).closest(".mobile-navigation").length&&!e(n.target).closest(".menu-toggle").length&&(a.attr("aria-expanded","false"),o.removeClass("is-open"),e("body").removeClass("mobile-menu-open"))}))})(jQuery);(function(e){e(function(){})})(jQuery); +(function(r){var n=r(".menu-toggle"),t=r(".mobile-navigation");n.length&&(n.on("click",function(){var e=r(this).attr("aria-expanded")==="true";r(this).attr("aria-expanded",!e),t.toggleClass("is-open"),e?r("body").removeClass("mobile-menu-open"):r("body").addClass("mobile-menu-open")}),r(document).on("keydown",function(e){e.key==="Escape"&&t.hasClass("is-open")&&(n.attr("aria-expanded","false"),t.removeClass("is-open"),r("body").removeClass("mobile-menu-open"))}),r(document).on("click",function(e){t.hasClass("is-open")&&!r(e.target).closest(".mobile-navigation").length&&!r(e.target).closest(".menu-toggle").length&&(n.attr("aria-expanded","false"),t.removeClass("is-open"),r("body").removeClass("mobile-menu-open"))}))})(jQuery);(function(r){var n={$form:null,$results:null,$filters:null,isFirstLoad:!0,isLoading:!1,init:function(){this.$form=r(".filters-form"),this.$results=r("#property-results"),this.$filters=r("#property-filters"),!(!this.$form.length||!this.$results.length)&&(this.bindEvents(),this.initFromUrl())},bindEvents:function(){var t=this;this.$form.on("submit",function(e){e.preventDefault(),t.filterProperties(1)}),this.$form.find("select").on("change",function(){t.filterProperties(1)}),r(".filters-reset").on("click",function(e){e.preventDefault(),t.resetFilters()}),this.$results.on("click",".pagination a",function(e){e.preventDefault();var i=t.getPageFromUrl(r(this).attr("href"));t.filterProperties(i)}),r(window).on("popstate",function(e){e.originalEvent.state&&e.originalEvent.state.propertyFilters&&(t.setFormFromState(e.originalEvent.state.propertyFilters),t.filterProperties(e.originalEvent.state.page||1,!1))})},initFromUrl:function(){var t=new URLSearchParams(window.location.search),e=!1;if(this.$form.find("select").each(function(){var s=r(this).attr("name");t.has(s)&&(r(this).val(t.get(s)),e=!0)}),e){var i=this.getFormState();i.page=parseInt(t.get("paged"))||1,history.replaceState({propertyFilters:i,page:i.page},"",window.location.href)}},filterProperties:function(t,e){if(!this.isLoading){e=e!==!1,t=t||1;var i=this,s=this.getFormData();s.paged=t,this.isLoading=!0,this.$filters.addClass("is-loading"),this.isFirstLoad&&this.$results.html('
'),r.ajax({url:homeprozAjax.ajaxUrl,type:"POST",data:{action:"homeproz_filter_properties",nonce:homeprozAjax.nonce,property_type:s.property_type,property_status:s.property_status,property_location:s.property_location,min_price:s.min_price,max_price:s.max_price,beds:s.beds,sort:s.sort,paged:t},success:function(o){o.success&&(i.$results.html(o.data.html),i.isFirstLoad=!1,e&&i.updateUrl(s,t),t>1&&r("html, body").animate({scrollTop:i.$filters.offset().top-100},300))},error:function(){i.$results.html('

Error

Something went wrong. Please try again.

')},complete:function(){i.isLoading=!1,i.$filters.removeClass("is-loading")}})}},getFormData:function(){return{property_type:this.$form.find('[name="property_type"]').val()||"",property_status:this.$form.find('[name="property_status"]').val()||"",property_location:this.$form.find('[name="property_location"]').val()||"",min_price:this.$form.find('[name="min_price"]').val()||"",max_price:this.$form.find('[name="max_price"]').val()||"",beds:this.$form.find('[name="beds"]').val()||"",sort:this.$form.find('[name="sort"]').val()||"newest"}},getFormState:function(){return this.getFormData()},setFormFromState:function(t){for(var e in t)this.$form.find('[name="'+e+'"]').val(t[e])},updateUrl:function(t,e){var i=new URL(homeprozAjax.archiveUrl);for(var s in t)t[s]&&t[s]!=="newest"&&i.searchParams.set(s,t[s]);e>1&&i.searchParams.set("paged",e);var o={propertyFilters:t,page:e};history.pushState(o,"",i.toString())},getPageFromUrl:function(t){var e=t.match(/[?&]paged=(\d+)/);return e?parseInt(e[1]):1},resetFilters:function(){this.$form.find("select").val(""),this.$form.find('[name="sort"]').val("newest"),this.filterProperties(1)}};r(function(){n.init()})})(jQuery);(function(r){r(function(){})})(jQuery); diff --git a/wp-content/themes/homeproz/functions.php b/wp-content/themes/homeproz/functions.php index e290e2bd..21cd80c5 100644 --- a/wp-content/themes/homeproz/functions.php +++ b/wp-content/themes/homeproz/functions.php @@ -34,3 +34,6 @@ require_once HOMEPROZ_DIR . '/inc/custom-post-types.php'; // ACF field definitions require_once HOMEPROZ_DIR . '/inc/acf-fields.php'; + +// AJAX handlers +require_once HOMEPROZ_DIR . '/inc/ajax-handlers.php'; diff --git a/wp-content/themes/homeproz/inc/ajax-handlers.php b/wp-content/themes/homeproz/inc/ajax-handlers.php new file mode 100644 index 00000000..16c62be1 --- /dev/null +++ b/wp-content/themes/homeproz/inc/ajax-handlers.php @@ -0,0 +1,231 @@ + 'property', + 'posts_per_page' => 12, + 'paged' => $paged, + ); + + // Taxonomy filters + $tax_query = array(); + + if ($property_type) { + $tax_query[] = array( + 'taxonomy' => 'property_type', + 'field' => 'slug', + 'terms' => $property_type, + ); + } + + if ($property_status) { + $tax_query[] = array( + 'taxonomy' => 'property_status', + 'field' => 'slug', + 'terms' => $property_status, + ); + } + + if ($property_location) { + $tax_query[] = array( + 'taxonomy' => 'property_location', + 'field' => 'slug', + 'terms' => $property_location, + ); + } + + if (!empty($tax_query)) { + $args['tax_query'] = $tax_query; + if (count($tax_query) > 1) { + $args['tax_query']['relation'] = 'AND'; + } + } + + // Meta query for price and bedrooms + $meta_query = array(); + + if ($min_price) { + $meta_query[] = array( + 'key' => 'property_price', + 'value' => $min_price, + 'type' => 'NUMERIC', + 'compare' => '>=', + ); + } + + if ($max_price) { + $meta_query[] = array( + 'key' => 'property_price', + 'value' => $max_price, + 'type' => 'NUMERIC', + 'compare' => '<=', + ); + } + + if ($beds) { + $meta_query[] = array( + 'key' => 'bedrooms', + 'value' => $beds, + 'type' => 'NUMERIC', + 'compare' => '>=', + ); + } + + if (!empty($meta_query)) { + $args['meta_query'] = $meta_query; + if (count($meta_query) > 1) { + $args['meta_query']['relation'] = 'AND'; + } + } + + // Sorting + switch ($sort) { + case 'oldest': + $args['orderby'] = 'date'; + $args['order'] = 'ASC'; + break; + case 'price_low': + $args['meta_key'] = 'property_price'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'ASC'; + break; + case 'price_high': + $args['meta_key'] = 'property_price'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; + case 'newest': + default: + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; + break; + } + + $properties = new WP_Query($args); + + ob_start(); + + // Results Meta + ?> +
+

+ found_posts > 0) : ?> + Showing found_posts); ?> + found_posts === 1 ? 'property' : 'properties'; ?> + + No properties found + +

+
+ + have_posts()) : ?> +
+ have_posts()) : + $properties->the_post(); + get_template_part('template-parts/property/property-card'); + endwhile; + ?> +
+ + add_query_arg('paged', '%#%', $base_url), + 'format' => '', + 'current' => max(1, $paged), + 'total' => $properties->max_num_pages, + 'prev_text' => '← Previous', + 'next_text' => 'Next →', + 'type' => 'array', + 'add_args' => $filter_args, + )); + + if ($pagination) : + ?> + + + + +
+

No Properties Found

+

We couldn't find any properties matching your criteria. Try adjusting your filters or browse all properties.

+ View All Properties +
+ + + $html, + 'found_posts' => $properties->found_posts, + 'max_pages' => $properties->max_num_pages, + )); +} +add_action('wp_ajax_homeproz_filter_properties', 'homeproz_ajax_filter_properties'); +add_action('wp_ajax_nopriv_homeproz_filter_properties', 'homeproz_ajax_filter_properties'); + +/** + * Localize script data for AJAX + */ +function homeproz_localize_ajax_data() { + if (is_post_type_archive('property') || is_tax('property_type') || is_tax('property_status') || is_tax('property_location')) { + wp_localize_script('homeproz-main', 'homeprozAjax', array( + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('homeproz_filter_nonce'), + 'archiveUrl' => get_post_type_archive_link('property'), + )); + } +} +add_action('wp_enqueue_scripts', 'homeproz_localize_ajax_data', 20); diff --git a/wp-content/themes/homeproz/src/main.js b/wp-content/themes/homeproz/src/main.js index c5fa37ea..74c6659f 100644 --- a/wp-content/themes/homeproz/src/main.js +++ b/wp-content/themes/homeproz/src/main.js @@ -8,6 +8,7 @@ import './main.scss'; // Import component JS import '../template-parts/header/navigation.js'; +import '../template-parts/property/property-filters.js'; (function($) { 'use strict'; diff --git a/wp-content/themes/homeproz/template-parts/property/property-filters.js b/wp-content/themes/homeproz/template-parts/property/property-filters.js new file mode 100644 index 00000000..70493dc4 --- /dev/null +++ b/wp-content/themes/homeproz/template-parts/property/property-filters.js @@ -0,0 +1,244 @@ +/** + * Property Filters JavaScript + * + * AJAX filtering for property archive + * + * @package HomeProz + */ + +(function($) { + 'use strict'; + + var PropertyFilters = { + // Elements + $form: null, + $results: null, + $filters: null, + + // State + isFirstLoad: true, + isLoading: false, + + /** + * Initialize + */ + init: function() { + this.$form = $('.filters-form'); + this.$results = $('#property-results'); + this.$filters = $('#property-filters'); + + if (!this.$form.length || !this.$results.length) { + return; + } + + this.bindEvents(); + this.initFromUrl(); + }, + + /** + * Bind events + */ + bindEvents: function() { + var self = this; + + // Form submission (Search button) + this.$form.on('submit', function(e) { + e.preventDefault(); + self.filterProperties(1); + }); + + // Filter changes (auto-submit on select change) + this.$form.find('select').on('change', function() { + self.filterProperties(1); + }); + + // Reset button + $('.filters-reset').on('click', function(e) { + e.preventDefault(); + self.resetFilters(); + }); + + // Pagination clicks (delegated) + this.$results.on('click', '.pagination a', function(e) { + e.preventDefault(); + var page = self.getPageFromUrl($(this).attr('href')); + self.filterProperties(page); + }); + + // Handle browser back/forward + $(window).on('popstate', function(e) { + if (e.originalEvent.state && e.originalEvent.state.propertyFilters) { + self.setFormFromState(e.originalEvent.state.propertyFilters); + self.filterProperties(e.originalEvent.state.page || 1, false); + } + }); + }, + + /** + * Initialize filters from URL params + */ + initFromUrl: function() { + var params = new URLSearchParams(window.location.search); + var hasFilters = false; + + // Set form values from URL + this.$form.find('select').each(function() { + var name = $(this).attr('name'); + if (params.has(name)) { + $(this).val(params.get(name)); + hasFilters = true; + } + }); + + // Save initial state + if (hasFilters) { + var state = this.getFormState(); + state.page = parseInt(params.get('paged')) || 1; + history.replaceState({ propertyFilters: state, page: state.page }, '', window.location.href); + } + }, + + /** + * Filter properties via AJAX + */ + filterProperties: function(page, updateHistory) { + if (this.isLoading) { + return; + } + + updateHistory = updateHistory !== false; + page = page || 1; + + var self = this; + var formData = this.getFormData(); + formData.paged = page; + + // Show loading state + this.isLoading = true; + this.$filters.addClass('is-loading'); + + // Show spinner only on first load + if (this.isFirstLoad) { + this.$results.html('
'); + } + + $.ajax({ + url: homeprozAjax.ajaxUrl, + type: 'POST', + data: { + action: 'homeproz_filter_properties', + nonce: homeprozAjax.nonce, + property_type: formData.property_type, + property_status: formData.property_status, + property_location: formData.property_location, + min_price: formData.min_price, + max_price: formData.max_price, + beds: formData.beds, + sort: formData.sort, + paged: page + }, + success: function(response) { + if (response.success) { + self.$results.html(response.data.html); + self.isFirstLoad = false; + + // Update URL + if (updateHistory) { + self.updateUrl(formData, page); + } + + // Scroll to top of results on page change + if (page > 1) { + $('html, body').animate({ + scrollTop: self.$filters.offset().top - 100 + }, 300); + } + } + }, + error: function() { + self.$results.html('

Error

Something went wrong. Please try again.

'); + }, + complete: function() { + self.isLoading = false; + self.$filters.removeClass('is-loading'); + } + }); + }, + + /** + * Get form data as object + */ + getFormData: function() { + return { + property_type: this.$form.find('[name="property_type"]').val() || '', + property_status: this.$form.find('[name="property_status"]').val() || '', + property_location: this.$form.find('[name="property_location"]').val() || '', + min_price: this.$form.find('[name="min_price"]').val() || '', + max_price: this.$form.find('[name="max_price"]').val() || '', + beds: this.$form.find('[name="beds"]').val() || '', + sort: this.$form.find('[name="sort"]').val() || 'newest' + }; + }, + + /** + * Get form state for history + */ + getFormState: function() { + return this.getFormData(); + }, + + /** + * Set form from state + */ + setFormFromState: function(state) { + for (var key in state) { + this.$form.find('[name="' + key + '"]').val(state[key]); + } + }, + + /** + * Update browser URL + */ + updateUrl: function(formData, page) { + var url = new URL(homeprozAjax.archiveUrl); + + // Add non-empty filters to URL + for (var key in formData) { + if (formData[key] && formData[key] !== 'newest') { + url.searchParams.set(key, formData[key]); + } + } + + // Add page if > 1 + if (page > 1) { + url.searchParams.set('paged', page); + } + + var state = { propertyFilters: formData, page: page }; + history.pushState(state, '', url.toString()); + }, + + /** + * Get page number from pagination URL + */ + getPageFromUrl: function(url) { + var match = url.match(/[?&]paged=(\d+)/); + return match ? parseInt(match[1]) : 1; + }, + + /** + * Reset filters + */ + resetFilters: function() { + this.$form.find('select').val(''); + this.$form.find('[name="sort"]').val('newest'); + this.filterProperties(1); + } + }; + + // Initialize on document ready + $(function() { + PropertyFilters.init(); + }); + +})(jQuery);