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.
cityaccepts either the slug or the raw value;typeandsortwant 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' );
#Your own gallery
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_querywork.wp_postmetais indexed onmeta_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 => trueon lists that need no pagination skips theSQL_CALC_FOUND_ROWSpass.osfec_slug_map(),osfec_type_options()andosfec_subtype_options()each run aDISTINCTquery on a cold cache (
6-hour transients). Call once per request, reuse the result.- Feeding
WP_Querya 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.