Blog · strony www, usługi programistyczne

Ukryj wpis z listingu WordPress, ale zachowaj URL – custom post type „prywatny”

2026-07-29

Prywatne wydarzenia, których nie wolno reklamować

Klient dzwoni: „Robimy zamknięty event dla wybranych klientów. Zaproszenia idą przez maila. Ale strona wydarzenia musi być dostępna, żeby ludzie mogli się zapisać. Tylko nie chcemy, żeby ktoś przypadkiem trafił na to z bloga albo z sekcji «nasze wydarzenia» na stronie głównej”.

Czyli: wpis w WordPress ma się nie pokazywać nigdzie, gdzie nie chcemy go pokazywać, ale działać pod bezpośrednim URL-em. Nie da się tego zrobić samym statusem „prywatny” wp-admin, bo tam prywatny znaczy „widoczny tylko dla zalogowanego admina” – a nam chodzi o coś innego. Ktoś z linkiem ma wejść bez logowania.

Rozwiązanie sprowadza się do jednego pola ACF i kilku filtrów zapytań w motywie.

ACF: pole „ukryj z listingów”

Do custom post type (u mnie były to wydarzenia – CPT o nazwie „events”) dodaję pole ACF typu true/false. Nazywam je „hide_from_listings” i umieszczam w prawej kolumnie edytora (sidebar), żeby było widoczne od razu przy edycji.

Konfiguracja pola w JSON-ie ACF (do wgrania jako group w acf-json/):

{
    "key": "group_hide_from_listings",
    "title": "Widoczność wpisu",
    "fields": [
        {
            "key": "field_hide_from_listings",
            "label": "Ukryj z listingów i pickerów",
            "name": "hide_from_listings",
            "type": "true_false",
            "instructions": "Wpis pozostaje dostępny pod bezpośrednim URL, ale nie pokaże się w listingach ani pickerach.",
            "ui": 1,
            "ui_on_text": "TAK",
            "ui_off_text": "NIE"
        }
    ],
    "location": [[{
        "param": "post_type",
        "operator": "==",
        "value": "events"
    }]],
    "position": "side"
}

Marketing dostaje przełącznik. Zaznacza TAK – wpis staje się „prywatny”. Zapisuje – gotowe. Adres URL wpisu dalej działa, ale znika z listingów. Cała reszta konfiguracji się nie zmienia.

Funkcja pomocnicza – jedno miejsce do edycji

Zanim wpiszemy filtr do pętli głównej, wyciągnę tablicę meta_query do osobnej funkcji pomocniczej. Bo ta sama tablica pojawi się za chwilę w kilku miejscach – w pre_get_posts i w każdym pickerze ACF. Jak w przyszłości zmieni się nazwa pola ACF, poprawiam jedno miejsce zamiast szukać po całym motywie.

/**
 * Zwraca meta_query wykluczajaca wpisy z hide_from_listings = 1.
 * Uzywana w pre_get_posts i w blokach ACF z pickerami.
 */
function get_hide_from_listings_meta_query() {
    return array(
        'relation' => 'OR',
        array(
            'key'     => 'hide_from_listings',
            'compare' => 'NOT EXISTS',
        ),
        array(
            'key'     => 'hide_from_listings',
            'value'   => '1',
            'compare' => '!=',
        ),
    );
}

Filtr na pętli głównej: /nowosci/, /blog/ itd.

WordPress w większości motywów renderuje listingi przez główną pętlę – domyślne WP_Query obsługiwane przez template hierarchy (index.php, home.php, archive.php). Wpięcie się w to działa przez akcję pre_get_posts.

Dodaję do functions.php motywu:

function moje_wyklucz_ukryte_z_petli( $query ) {
    if ( is_admin() || !$query->is_main_query() ) {
        return;
    }

    // Strony gdzie renderujemy listingi - blog, archiwum autora/tagu,
    // wyszukiwarka, archiwum CPT events (domyslny listing /events/)
    $czy_listing = $query->is_home()
        || $query->is_author()
        || $query->is_tag()
        || $query->is_search()
        || $query->is_post_type_archive( 'events' );

    if ( $czy_listing ) {
        // Zachowaj ewentualne istniejace meta_query (od innych wtyczek)
        // i dolacz nasze jako kolejny warunek
        $meta_query = $query->get( 'meta_query' ) ?: array();
        $meta_query[] = get_hide_from_listings_meta_query();
        $query->set( 'meta_query', $meta_query );
    }
}
add_action( 'pre_get_posts', 'moje_wyklucz_ukryte_z_petli' );

Dwa niuanse warto zaznaczyć. Po pierwsze – is_post_type_archive(’events’). Jeśli custom post type ma has_archive => true, WordPress automatycznie generuje listing pod /events/. Bez tego warunku ukryte wydarzenia dalej by się tam pokazywały mimo wszystkich innych filtrów.

Po drugie – pobranie istniejącego meta_query przez $query->get(’meta_query’) ?: array() i dołączenie naszego zamiast overwrite. Jeśli inna wtyczka (WooCommerce, MemberPress, cokolwiek) też dorzuca meta_query na tej samej pętli, nasz filter dopisuje się obok zamiast nadpisywać. Może się nigdy nie przydać, ale kosztuje jedną linijkę więcej.

Dlaczego relation OR z NOT EXISTS

Ten fragment jest kluczowy i nie oczywisty na pierwszy rzut oka. Chcę wykluczyć wpisy oznaczone jako ukryte – ale WIĘKSZOŚĆ wpisów nie ma w ogóle tego pola meta (bo je dodałam tylko dla events, a w listingu są też zwykłe post, case-studies itd.).

Gdybym napisała prostsze zapytanie typu „hide_from_listings != 1”, WordPress by wykluczył wpisy oznaczone jako 1, ale też pominął wszystkie te, które w ogóle nie mają tego pola meta (bo w SQL NULL nie jest równe „1”, ale też nie jest różne od „1” w klasyczny sposób).

Rozwiązanie: OR z dwoma warunkami. Weź wpis, jeśli nie ma pola meta (NOT EXISTS) LUB ma pole meta ale z wartością inną niż 1 (!=). Pierwsze łapie masę wpisów bez tego pola, drugie łapie wpisy oznaczone świadomie jako publiczne. Wykluczone zostają tylko te, które mają wartość dokładnie „1”.

Filtr na pickerach ACF w motywie

Motyw ma bloki typu „wybrane wpisy” z konfiguracją ACF – na stronie głównej slidery, w footerze polecane wpisy, na stronach tematycznych karuzele. Wszystkie one używają WP_Query albo get_posts, ale nie przechodzą przez pre_get_posts (bo to nie jest główna pętla).

Każdy z tych bloków trzeba tak samo obłożyć meta_query. Dzięki funkcji pomocniczej wywołanie w bloku ACF sprowadza się do jednej linijki:

// W szablonie bloku posts-picker.php albo events-picker.php:
$picked_posts = get_posts(array(
    'post_type'      => 'events',
    'posts_per_page' => 4,
    'orderby'        => 'date',
    'order'          => 'DESC',
    'post_status'    => 'publish',
    'meta_query'     => array( get_hide_from_listings_meta_query() ),
));

Nie da się tego zrobić globalnie jednym filtrem – bo pickery różnie budują zapytania i różnie renderują. Ale wywołanie funkcji pomocniczej jest krótkie i czytelne. U mnie musiałam ją dodać w trzech miejscach: dwóch blokach ACF z pickerami i głównej pętli listingu blog.

Czego to NIE robi

Uczciwie: to rozwiązanie NIE ustawia wpisu jako noindex dla Google. Wpis dalej ma URL, dalej jest w sitemap XML wtyczki SEO (u mnie Yoast), dalej może się pojawić w wynikach wyszukiwania Google gdy ktoś wpisze frazę pasującą do treści.

Klient chciał TYLKO: nie pokazywać w listingach na stronie. Google indexing to osobna sprawa. Jeśli w danym przypadku dodatkowo trzeba by zablokować Google – dorobiłabym filtr do Yoast, żeby oznaczał te wpisy jako noindex. Ale to inna decyzja biznesowa i inna warstwa.

Podsumowanie

Trzy klocki, cały workflow:

  • Pole ACF true/false w prawej kolumnie edytora – marketing sam ustawia widoczność
  • Filtr pre_get_posts dla głównych pętli (blog, tag, autor, search, archiwum CPT)
  • Meta_query w każdym pickerze ACF w motywie – inline w bloku PHP

Cały mechanizm oparty na jednym meta polu, filtrach które nie ruszają post_status i nie generują 404. Bezpośredni URL działa bez zmian. Marketing dostaje kontrolę bez ryzyka zepsucia strony.

Wróć do wszystkich wpisów

Masz projekt do omówienia?

Napisz, o co chodzi - nie musi być technicznie, od tego jestem ja. Odezwę się i powiem wprost, czy i jak mogę pomóc.