{"id":15,"date":"2026-09-16T10:28:30","date_gmt":"2026-09-16T10:28:30","guid":{"rendered":"https:\/\/docs.latw.ai\/uncategorized\/custom-ui\/"},"modified":"2026-09-16T10:28:30","modified_gmt":"2026-09-16T10:28:30","slug":"custom-ui","status":"publish","type":"post","link":"https:\/\/docs.latw.ai\/pl\/esti-to-wordpress\/custom-ui\/","title":{"rendered":"Custom UI"},"content":{"rendered":"<h1>Building a custom UI<\/h1>\n<p>When the bundled templates are the wrong starting point, ignore them. Offers are ordinary posts, so anything you can<br \/>\nbuild with <code>WP_Query<\/code> you can build here \u2014 while still reusing the plugin&#8217;s filtering contract, its slug resolution and<br \/>\nits data helpers.<\/p>\n<h2>The pieces you build on<\/h2>\n<table>\n<thead>\n<tr>\n<th>Piece<\/th>\n<th>What it gives you<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>OSFEC_CPT::POST_TYPE<\/code><\/td>\n<td><code>'osfec_offer'<\/code><\/td>\n<\/tr>\n<tr>\n<td><code>OSFEC_Query::request()<\/code><\/td>\n<td>The whitelisted, sanitized GET parameters<\/td>\n<\/tr>\n<tr>\n<td><code>OSFEC_Query::build_args( $request, $base )<\/code><\/td>\n<td>Those parameters as <code>WP_Query<\/code> arguments, merged into your own base<\/td>\n<\/tr>\n<tr>\n<td><code>OSFEC_Query::archive_url( $request )<\/code><\/td>\n<td>The archive URL carrying a set of filters<\/td>\n<\/tr>\n<tr>\n<td><code>osfec_field()<\/code>, <code>osfec_photos()<\/code>, <code>osfec_agent()<\/code>, \u2026<\/td>\n<td>Field access and formatting \u2014 see the <a href=\"\/esti-to-wordpress\/php-api\">PHP API<\/a><\/td>\n<\/tr>\n<tr>\n<td><code>osfec_type_options()<\/code>, <code>osfec_slug_map()<\/code><\/td>\n<td>Ready-made option lists for your own form controls<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2>A list of offers<\/h2>\n<pre><code class=\"language-php\">$request = OSFEC_Query::request();\n\n$args = OSFEC_Query::build_args(\n    $request,\n    array(\n        'post_type'      =&gt; OSFEC_CPT::POST_TYPE,\n        'post_status'    =&gt; 'publish',\n        'posts_per_page' =&gt; 12,\n        'no_found_rows'  =&gt; true, \/\/ drop this if you need pagination\n    )\n);\n\n$offers = new WP_Query( $args );\n\nif ( $offers-&gt;have_posts() ) :\n    while ( $offers-&gt;have_posts() ) :\n        $offers-&gt;the_post();\n        \/\/ your markup\n    endwhile;\nendif;\n\nwp_reset_postdata();<\/code><\/pre>\n<p><code>build_args()<\/code> handles the parts that are easy to get wrong: the meta query for every filter, <code>DECIMAL<\/code> casting on the<br \/>\nprice and area ranges, sorting by <code>meta_value_num<\/code>, and resolving <code>?city=bialystok<\/code> back to the stored <code>Bia\u0142ystok<\/code>.<\/p>\n<p>Passing an empty <code>$request<\/code> gives you a plain, unfiltered list with the default ordering \u2014 useful for a &#8220;featured<br \/>\noffers&#8221; strip that must ignore whatever the visitor filtered elsewhere on the page.<\/p>\n<h2>Hard-coding filters, but letting visitors override them<\/h2>\n<p>The shortcode&#8217;s own behaviour, in three lines: start from the request, and only fill in a default where the visitor<br \/>\nsupplied nothing.<\/p>\n<pre><code class=\"language-php\">$request = OSFEC_Query::request();\n\nforeach ( array( 'type' =&gt; 'mieszkanie', 'city' =&gt; 'Bia\u0142ystok' ) as $key =&gt; $value ) {\n    if ( ! isset( $request[ $key ] ) ) {\n        $request[ $key ] = $value;\n    }\n}<\/code><\/pre>\n<p>To <em>force<\/em> a filter regardless of the URL, set it after the merge \u2014 or bypass <code>$request<\/code> entirely and add your own<br \/>\n<code>meta_query<\/code> clause to the <code>$base<\/code> array; <code>build_args()<\/code> merges into whatever <code>meta_query<\/code> it finds there.<\/p>\n<h2>Your own filter form<\/h2>\n<p>The filtering contract is the GET parameter names. Reproduce them and everything else keeps working \u2014 the archive, the<br \/>\nshortcode, caching, the back button, shareable links.<\/p>\n<pre><code class=\"language-php\">$request = OSFEC_Query::request();\n$types   = osfec_type_options();          \/\/ slug =&gt; label\n$cities  = osfec_slug_map( 'city' );      \/\/ slug =&gt; raw value\n?&gt;\n&lt;form method=\"get\" action=\"&lt;?php echo esc_url( OSFEC_Query::archive_url() ); ?&gt;\"&gt;\n    &lt;select name=\"type\"&gt;\n        &lt;option value=\"\"&gt;&lt;?php esc_html_e( 'Any', 'your-theme' ); ?&gt;&lt;\/option&gt;\n        &lt;?php foreach ( $types as $slug =&gt; $label ) : ?&gt;\n            &lt;option value=\"&lt;?php echo esc_attr( $slug ); ?&gt;\"\n                &lt;?php selected( isset( $request['type'] ) ? $request['type'] : '', $slug ); ?&gt;&gt;\n                &lt;?php echo esc_html( $label ); ?&gt;\n            &lt;\/option&gt;\n        &lt;?php endforeach; ?&gt;\n    &lt;\/select&gt;\n\n    &lt;input type=\"number\" name=\"price_max\" min=\"0\"\n        value=\"&lt;?php echo esc_attr( isset( $request['price_max'] ) ? $request['price_max'] : '' ); ?&gt;\"&gt;\n\n    &lt;button type=\"submit\"&gt;&lt;?php esc_html_e( 'Search', 'your-theme' ); ?&gt;&lt;\/button&gt;\n&lt;\/form&gt;<\/code><\/pre>\n<p>Rules:<\/p>\n<ul>\n<li>Use <code>method=\"get\"<\/code> and no nonce. These are public, cacheable, indexable URLs by design.<\/li>\n<li>Keep the parameter names exactly: <code>type<\/code>, <code>city<\/code>, <code>transaction<\/code>, <code>agent<\/code>, <code>price_min<\/code>, <code>price_max<\/code>, <code>rooms<\/code>,<br \/>\n<code>area_min<\/code>, <code>area_max<\/code>, <code>sort<\/code>. Anything else is ignored.<\/li>\n<li>Carry the filters you are not editing as hidden inputs, or a sort form will wipe the active filters.<\/li>\n<li><code>city<\/code> accepts either the slug or the raw value; <code>type<\/code> and <code>sort<\/code> want the slug.<\/li>\n<li>Full parameter reference: <a href=\"\/esti-to-wordpress\/shortcode-and-block-reference#url-parameters\">Shortcode and block reference<\/a>.<\/li>\n<\/ul>\n<h2>Taking over the archive query instead<\/h2>\n<p>If you only want the archive to behave differently \u2014 a different page size, a fixed extra condition \u2014 leave the<br \/>\ntemplates alone and hook <code>pre_get_posts<\/code> after the plugin, which registers its own at the default priority:<\/p>\n<pre><code class=\"language-php\">add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query-&gt;is_main_query() || ! $query-&gt;is_post_type_archive( 'osfec_offer' ) ) {\n        return;\n    }\n\n    $query-&gt;set( 'posts_per_page', 24 );\n}, 20 );<\/code><\/pre>\n<h2>Your own single offer page<\/h2>\n<p>Any of the usual routes works: a <code>single-osfec_offer.php<\/code> in the theme, a <code>template_include<\/code> filter of your own, or a<br \/>\nblock-theme template. Two things to carry over:<\/p>\n<ul>\n<li>Wrap the page in <code>osfec_header()<\/code> \/ <code>osfec_footer()<\/code> unless you are producing the chrome yourself \u2014 on a block theme<br \/>\n<code>get_header()<\/code> yields the compatibility stub, not the theme&#8217;s real header.<\/li>\n<li>Read fields through the helpers rather than <code>get_post_meta()<\/code>: <code>osfec_field()<\/code> for values, <code>osfec_attribute_label()<\/code><br \/>\nfor dictionary-backed ones, <code>osfec_agent()<\/code> for the merged agent record.<\/li>\n<\/ul>\n<h2>Assets<\/h2>\n<p>The plugin registers two handles:<\/p>\n<table>\n<thead>\n<tr>\n<th>Handle<\/th>\n<th>File<\/th>\n<th>Loaded on<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>osfec-frontend<\/code><\/td>\n<td><code>assets\/css\/frontend.css<\/code><\/td>\n<td>Archive, single offers, shortcode, block<\/td>\n<\/tr>\n<tr>\n<td><code>osfec-gallery<\/code><\/td>\n<td><code>assets\/js\/gallery.js<\/code><\/td>\n<td>Single offers that actually have photos<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>Dropping the plugin&#8217;s CSS entirely:<\/p>\n<pre><code class=\"language-php\">add_action( 'wp_enqueue_scripts', function () {\n    wp_dequeue_style( 'osfec-frontend' );\n}, 20 );<\/code><\/pre>\n<p>Loading your own after it:<\/p>\n<pre><code class=\"language-php\">wp_enqueue_style( 'my-offers', get_stylesheet_directory_uri() . '\/offers.css', array( 'osfec-frontend' ), '1.0' );<\/code><\/pre>\n<h2>Your own gallery<\/h2>\n<p><code>osfec_photos()<\/code> returns the full-size URLs and <code>osfec_photo_thumb()<\/code> converts one to its thumbnail variant, which is<br \/>\neverything a slider needs:<\/p>\n<pre><code class=\"language-php\">$photos = osfec_photos();\n\nforeach ( $photos as $index =&gt; $url ) {\n    printf(\n        '&lt;img src=\"%s\" alt=\"%s\" loading=\"%s\" decoding=\"async\"&gt;',\n        esc_url( $url ),\n        esc_attr( get_the_title() ),\n        0 === $index ? 'eager' : 'lazy'\n    );\n}<\/code><\/pre>\n<p>If you replace the bundled slider, dequeue <code>osfec-gallery<\/code> \u2014 it binds to <code>data-osfec-*<\/code> attributes and does nothing<br \/>\nwithout them, but there is no point shipping the bytes.<\/p>\n<p>Photos are hotlinked from the Esti CDN: no attachment IDs, no <code>wp_get_attachment_image()<\/code>, no generated sizes. Only two<br \/>\nvariants exist, <code>_max<\/code> and <code>_min<\/code>.<\/p>\n<h2>Performance notes<\/h2>\n<ul>\n<li>Offer filtering is <code>meta_query<\/code> work. <code>wp_postmeta<\/code> is indexed on <code>meta_key<\/code>, so a handful of clauses over a few<br \/>\nhundred offers is fine \u2014 a catalogue two orders of magnitude larger is not what this data model was designed for.<\/li>\n<li><code>no_found_rows =&gt; true<\/code> on lists that need no pagination skips the <code>SQL_CALC_FOUND_ROWS<\/code> pass.<\/li>\n<li><code>osfec_slug_map()<\/code>, <code>osfec_type_options()<\/code> and <code>osfec_subtype_options()<\/code> each run a <code>DISTINCT<\/code> query on a cold cache (<br \/>\n6-hour transients). Call once per request, reuse the result.<\/li>\n<li>Feeding <code>WP_Query<\/code> a list of post IDs and letting WordPress prime the meta cache beats fetching meta post by post.<\/li>\n<\/ul>\n<h2>What you cannot extend without changing the plugin<\/h2>\n<p>There are <strong>no custom actions or filters<\/strong>. Nothing in the import path is pluggable, in particular:<\/p>\n<ul>\n<li>which offers are imported, beyond the settings \u2014 <code>OSFEC_Sync::should_import()<\/code><\/li>\n<li>how a payload maps to meta, and which fields exist at all \u2014 <code>OSFEC_Mapper::map()<\/code><\/li>\n<li>what the JSON-LD contains \u2014 <code>OSFEC_Schema<\/code><\/li>\n<li>the API endpoints, page size and timeouts \u2014 <code>OSFEC_API<\/code><\/li>\n<\/ul>\n<p>Changing any of those means patching the plugin. If you do fork the mapper, <strong>bump <code>OSFEC_MAPPER_VERSION<\/code><\/strong> in the main<br \/>\nplugin file: it is part of the change-detection hash, so without a bump every offer looks unchanged and your new mapping<br \/>\nnever reaches the database.<\/p>\n<p>The natural places for extension points, should they be added upstream, are the return value of <code>should_import()<\/code>, the<br \/>\nmapped array from <code>map()<\/code>, and the JSON-LD graph before it is printed.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Building a custom UI When the bundled templates are the wrong starting point, ignore them. Offers are ordinary posts, so anything you can build with WP_Query\u2026<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_latwm_translated_slug":"","footnotes":""},"categories":[2],"tags":[],"class_list":["post-15","post","type-post","status-publish","format-standard","hentry","category-esti-to-wordpress"],"_links":{"self":[{"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/posts\/15","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/comments?post=15"}],"version-history":[{"count":1,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/posts\/15\/revisions"}],"predecessor-version":[{"id":54,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/posts\/15\/revisions\/54"}],"wp:attachment":[{"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/media?parent=15"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/categories?post=15"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/docs.latw.ai\/pl\/wp-json\/wp\/v2\/tags?post=15"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}