Skip to content

Custom UI

On this page

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 you can build here — while still reusing the plugin’s filtering contract, its slug resolution and
its data helpers.

#The pieces you build on

Piece What it gives you
OSFEC_CPT::POST_TYPE 'osfec_offer'
OSFEC_Query::request() The whitelisted, sanitized GET parameters
OSFEC_Query::build_args( $request, $base ) Those parameters as WP_Query arguments, merged into your own base
OSFEC_Query::archive_url( $request ) The archive URL carrying a set of filters
osfec_field(), osfec_photos(), osfec_agent(), … Field access and formatting — see the PHP API
osfec_type_options(), osfec_slug_map() Ready-made option lists for your own form controls

#A list of offers

$request = OSFEC_Query::request();

$args = OSFEC_Query::build_args(
    $request,
    array(
        'post_type'      => OSFEC_CPT::POST_TYPE,
        'post_status'    => 'publish',
        'posts_per_page' => 12,
        'no_found_rows'  => true, // drop this if you need pagination
    )
);

$offers = new WP_Query( $args );

if ( $offers->have_posts() ) :
    while ( $offers->have_posts() ) :
        $offers->the_post();
        // your markup
    endwhile;
endif;

wp_reset_postdata();

build_args() handles the parts that are easy to get wrong: the meta query for every filter, DECIMAL casting on the
price and area ranges, sorting by meta_value_num, and resolving ?city=bialystok back to the stored Białystok.

Passing an empty $request gives you a plain, unfiltered list with the default ordering — useful for a “featured
offers” strip that must ignore whatever the visitor filtered elsewhere on the page.

#Hard-coding filters, but letting visitors override them

The shortcode’s own behaviour, in three lines: start from the request, and only fill in a default where the visitor
supplied nothing.

$request = OSFEC_Query::request();

foreach ( array( 'type' => 'mieszkanie', 'city' => 'Białystok' ) as $key => $value ) {
    if ( ! isset( $request[ $key ] ) ) {
        $request[ $key ] = $value;
    }
}

To force a filter regardless of the URL, set it after the merge — or bypass $request entirely and add your own
meta_query clause to the $base array; build_args() merges into whatever meta_query it finds there.

#Your own filter form

The filtering contract is the GET parameter names. Reproduce them and everything else keeps working — the archive, the
shortcode, caching, the back button, shareable links.

$request = OSFEC_Query::request();
$types   = osfec_type_options();          // slug => label
$cities  = osfec_slug_map( 'city' );      // slug => raw value
?>
<form method="get" action="<?php echo esc_url( OSFEC_Query::archive_url() ); ?>">
    <select name="type">
        <option value=""><?php esc_html_e( 'Any', 'your-theme' ); ?></option>
        <?php foreach ( $types as $slug => $label ) : ?>
            <option value="<?php echo esc_attr( $slug ); ?>"
                <?php selected( isset( $request['type'] ) ? $request['type'] : '', $slug ); ?>>
                <?php echo esc_html( $label ); ?>
            </option>
        <?php endforeach; ?>
    </select>

    <input type="number" name="price_max" min="0"
        value="<?php echo esc_attr( isset( $request['price_max'] ) ? $request['price_max'] : '' ); ?>">

    <button type="submit"><?php esc_html_e( 'Search', 'your-theme' ); ?></button>
</form>

Rules:

  • Use method="get" and no nonce. These are public, cacheable, indexable URLs by design.
  • Keep the parameter names exactly: type, city, transaction, agent, price_min, price_max, rooms,
    area_min, area_max, sort. Anything else is ignored.
  • Carry the filters you are not editing as hidden inputs, or a sort form will wipe the active filters.
  • city accepts either the slug or the raw value; type and sort want the slug.
  • Full parameter reference: Shortcode and block reference.

#Taking over the archive query instead

If you only want the archive to behave differently — a different page size, a fixed extra condition — leave the
templates alone and hook pre_get_posts after the plugin, which registers its own at the default priority:

add_action( 'pre_get_posts', function ( $query ) {
    if ( is_admin() || ! $query->is_main_query() || ! $query->is_post_type_archive( 'osfec_offer' ) ) {
        return;
    }

    $query->set( 'posts_per_page', 24 );
}, 20 );

#Your own single offer page

Any of the usual routes works: a single-osfec_offer.php in the theme, a template_include filter of your own, or a
block-theme template. Two things to carry over:

  • Wrap the page in osfec_header() / osfec_footer() unless you are producing the chrome yourself — on a block theme
    get_header() yields the compatibility stub, not the theme’s real header.
  • Read fields through the helpers rather than get_post_meta(): osfec_field() for values, osfec_attribute_label()
    for dictionary-backed ones, osfec_agent() for the merged agent record.

#Assets

The plugin registers two handles:

Handle File Loaded on
osfec-frontend assets/css/frontend.css Archive, single offers, shortcode, block
osfec-gallery assets/js/gallery.js Single offers that actually have photos

Dropping the plugin’s CSS entirely:

add_action( 'wp_enqueue_scripts', function () {
    wp_dequeue_style( 'osfec-frontend' );
}, 20 );

Loading your own after it:

wp_enqueue_style( 'my-offers', get_stylesheet_directory_uri() . '/offers.css', array( 'osfec-frontend' ), '1.0' );

osfec_photos() returns the full-size URLs and osfec_photo_thumb() converts one to its thumbnail variant, which is
everything a slider needs:

$photos = osfec_photos();

foreach ( $photos as $index => $url ) {
    printf(
        '<img src="%s" alt="%s" loading="%s" decoding="async">',
        esc_url( $url ),
        esc_attr( get_the_title() ),
        0 === $index ? 'eager' : 'lazy'
    );
}

If you replace the bundled slider, dequeue osfec-gallery — it binds to data-osfec-* attributes and does nothing
without them, but there is no point shipping the bytes.

Photos are hotlinked from the Esti CDN: no attachment IDs, no wp_get_attachment_image(), no generated sizes. Only two
variants exist, _max and _min.

#Performance notes

  • Offer filtering is meta_query work. wp_postmeta is indexed on meta_key, so a handful of clauses over a few
    hundred offers is fine — a catalogue two orders of magnitude larger is not what this data model was designed for.
  • no_found_rows => true on lists that need no pagination skips the SQL_CALC_FOUND_ROWS pass.
  • osfec_slug_map(), osfec_type_options() and osfec_subtype_options() each run a DISTINCT query on a cold cache (
    6-hour transients). Call once per request, reuse the result.
  • Feeding WP_Query a list of post IDs and letting WordPress prime the meta cache beats fetching meta post by post.

#What you cannot extend without changing the plugin

There are no custom actions or filters. Nothing in the import path is pluggable, in particular:

  • which offers are imported, beyond the settings — OSFEC_Sync::should_import()
  • how a payload maps to meta, and which fields exist at all — OSFEC_Mapper::map()
  • what the JSON-LD contains — OSFEC_Schema
  • the API endpoints, page size and timeouts — OSFEC_API

Changing any of those means patching the plugin. If you do fork the mapper, bump OSFEC_MAPPER_VERSION in the main
plugin file: it is part of the change-detection hash, so without a bump every offer looks unchanged and your new mapping
never reaches the database.

The natural places for extension points, should they be added upstream, are the return value of should_import(), the
mapped array from map(), and the JSON-LD graph before it is printed.