<?php
declare(strict_types=1);
namespace MeinWeimar;
final class Renderer
{
public static function hooks(): void
{
add_action('wp_enqueue_scripts', [self::class, 'register_assets']);
}
public static function register_assets(): void
{
wp_register_style('meinweimar-frontend', URL . 'assets/css/frontend.css', [], VERSION);
wp_register_script('meinweimar-frontend', URL . 'assets/js/frontend.js', [], VERSION, true);
wp_register_script('meinweimar-google-maps', URL . 'assets/js/google-maps-adapter.js', ['meinweimar-frontend'], VERSION, true);
wp_localize_script('meinweimar-frontend', 'MeinWeimarCore', [
'restUrl' => esc_url_raw(rest_url('meinweimar/v1/')),
'isLoggedIn' => is_user_logged_in(),
'restNonce' => is_user_logged_in() ? wp_create_nonce('wp_rest') : '',
'loginUrl' => wp_login_url(wp_get_referer() ?: home_url('/')),
'geo' => Settings::public_config()['geo'],
'googleMaps' => Settings::google_maps_frontend_config(),
'contentSchemas' => Settings::public_config()['content_schemas'],
'contentCategories' => Settings::public_config()['content_categories'],
// The mixed discovery/map surface is deliberately limited to
// geo-enabled community resources. Editorial News/Guides use
// their own list/detail surfaces and must never enter a map call.
'resourceTypes' => ContentTypes::geo_routes(),
'geoResourceTypes' => ContentTypes::geo_routes(),
'resourceLabels' => array_map(static fn (array $definition): string => (string) $definition['label'], ContentTypes::definitions()),
'strings' => [
'loading' => __('Wird geladen …', 'meinweimar-core'),
'noResults' => __('Keine Treffer gefunden.', 'meinweimar-core'),
'error' => __('Die Daten konnten nicht geladen werden.', 'meinweimar-core'),
'map' => __('Kartenübersicht', 'meinweimar-core'),
'list' => __('Liste', 'meinweimar-core'),
'distance' => __('Entfernung', 'meinweimar-core'),
'distanceUnit' => __('km', 'meinweimar-core'),
'radiusLabel' => __('Radius (km)', 'meinweimar-core'),
'radiusMetersUnit' => __('m', 'meinweimar-core'),
'radiusKilometersUnit' => __('km', 'meinweimar-core'),
'noGeo' => __('Für diese Treffer sind keine öffentlichen Positionen verfügbar.', 'meinweimar-core'),
'markerClusterLabel' => __('Marker-Cluster: %d Einträge', 'meinweimar-core'),
'currentLocationLabel' => __('Mein Standort', 'meinweimar-core'),
'searchLocationLabel' => __('Gesuchte Adresse', 'meinweimar-core'),
'close' => __('Schließen', 'meinweimar-core'),
'openDetails' => __('Details öffnen', 'meinweimar-core'),
'listingTransactionSale' => __('Kaufen', 'meinweimar-core'),
'listingTransactionRent' => __('Mieten', 'meinweimar-core'),
'listingPriceFree' => __('Zu verschenken', 'meinweimar-core'),
'listingPriceOnRequest' => __('Auf Anfrage', 'meinweimar-core'),
'listingAreaUnit' => __('m²', 'meinweimar-core'),
'listingRoomsUnit' => __('Zimmer', 'meinweimar-core'),
'useLocation' => __('Meinen Standort verwenden', 'meinweimar-core'),
'locationReady' => __('Standort aktiv', 'meinweimar-core'),
'locationDenied' => __('Standort konnte nicht verwendet werden.', 'meinweimar-core'),
'addressSearchLabel' => __('Adresse suchen', 'meinweimar-core'),
'addressSearchPlaceholder' => __('Adresse oder Ort eingeben …', 'meinweimar-core'),
'addressSearchSubmit' => __('Adresse suchen', 'meinweimar-core'),
'addressSearchNoResults' => __('Keine passenden Adressen gefunden.', 'meinweimar-core'),
'addressSearchUnavailable' => __('Die Adresssuche ist derzeit nicht verfügbar.', 'meinweimar-core'),
'addressSearchChoose' => __('Adresse verwenden', 'meinweimar-core'),
'mapProviderPending' => __('Keine Kartenquelle konfiguriert. Die Karte wird angezeigt, sobald ein freigegebener Anbieter hinterlegt ist.', 'meinweimar-core'),
'mapAdapterPending' => __('Die Kartenquelle ist konfiguriert, aber der Website-Kartenadapter ist noch nicht eingebunden.', 'meinweimar-core'),
'imageSuggestCta' => __('Bild vorschlagen', 'meinweimar-core'),
'imageSuggestBody' => __('Fehlt ein Bild oder ist nur ein Platzhalter vorhanden? Schlage ein passendes Bild vor. Jeder Vorschlag wird vor der Veröffentlichung geprüft.', 'meinweimar-core'),
'imageSuggestLogin' => __('Zum Vorschlagen eines Bildes musst du angemeldet sein.', 'meinweimar-core'),
'imageSuggestLoginAction' => __('Anmelden', 'meinweimar-core'),
'imageSuggestChoose' => __('Bild auswählen', 'meinweimar-core'),
'imageSuggestPreview' => __('Vorschau', 'meinweimar-core'),
'imageSuggestRightsHeading' => __('Rechte und Bestätigung', 'meinweimar-core'),
'imageSuggestRightsHolder' => __('Ich bin Urheber des Bildes oder besitze ausreichende Nutzungsrechte.', 'meinweimar-core'),
'imageSuggestLawfulSource' => __('Das Bild wurde nicht unrechtmäßig aus dem Internet übernommen.', 'meinweimar-core'),
'imageSuggestPersonsConsent' => __('Abgebildete Personen haben zugestimmt oder sind nicht identifizierbar.', 'meinweimar-core'),
'imageSuggestPlatformLicense' => __('Ich räume MeinWeimar die für Anzeige, Speicherung und Verarbeitung erforderlichen Nutzungsrechte ein.', 'meinweimar-core'),
'imageSuggestOptionalHeading' => __('Optionale Angaben', 'meinweimar-core'),
'imageSuggestSource' => __('Quelle', 'meinweimar-core'),
'imageSuggestLicense' => __('Lizenz', 'meinweimar-core'),
'imageSuggestPhotographer' => __('Fotograf / Urheber', 'meinweimar-core'),
'imageSuggestDescription' => __('Bildbeschreibung', 'meinweimar-core'),
'imageSuggestSubmit' => __('Bildvorschlag einreichen', 'meinweimar-core'),
'imageSuggestSubmitting' => __('Bildvorschlag wird eingereicht …', 'meinweimar-core'),
'imageSuggestSubmitted' => __('Bildvorschlag eingereicht. Er wird nun moderiert.', 'meinweimar-core'),
'imageSuggestError' => __('Der Bildvorschlag konnte nicht eingereicht werden.', 'meinweimar-core'),
'imageSuggestErrorAuth' => __('Zum Einreichen eines Bildvorschlags musst du angemeldet sein.', 'meinweimar-core'),
'imageSuggestErrorValidation' => __('Bitte prüfe Bilddatei und Pflichtangaben.', 'meinweimar-core'),
'imageSuggestErrorConflict' => __('Dieser Bildvorschlag steht im Konflikt mit einem bestehenden Vorschlag.', 'meinweimar-core'),
'imageSuggestErrorRateLimit' => __('Zu viele Bildvorschläge. Bitte später erneut versuchen.', 'meinweimar-core'),
'imageSuggestErrorSecurity' => __('Die Sicherheitsprüfung hat diesen Bildvorschlag abgewiesen oder ist noch nicht abgeschlossen.', 'meinweimar-core'),
'imageSuggestErrorGeneric' => __('Der Bildvorschlag konnte nicht verarbeitet werden.', 'meinweimar-core'),
'imageSuggestStatus' => __('Status', 'meinweimar-core'),
'imageSuggestStatusSubmitted' => __('Eingereicht', 'meinweimar-core'),
'imageSuggestStatusInReview' => __('In Prüfung', 'meinweimar-core'),
'imageSuggestStatusAccepted' => __('Akzeptiert', 'meinweimar-core'),
'imageSuggestStatusRejected' => __('Abgelehnt', 'meinweimar-core'),
'imageSuggestStatusNeedsInfo' => __('Rückfrage', 'meinweimar-core'),
'imageSuggestHistory' => __('Meine Bildvorschläge für diesen Eintrag', 'meinweimar-core'),
'imageSuggestSubmittedAt' => __('Eingereicht: {date}', 'meinweimar-core'),
'imageSuggestHistoryError' => __('Die Bildvorschläge konnten nicht geladen werden.', 'meinweimar-core'),
'imageSuggestHistoryRetry' => __('Erneut versuchen', 'meinweimar-core'),
'imageSuggestResponseBody' => __('Bitte ergänze die angeforderten Angaben. Deine Antwort wird erneut geprüft.', 'meinweimar-core'),
'imageSuggestResponseLabel' => __('Deine Antwort', 'meinweimar-core'),
'imageSuggestResponseSubmit' => __('Antwort senden', 'meinweimar-core'),
'imageSuggestResponseSubmitting' => __('Antwort wird gesendet …', 'meinweimar-core'),
'imageSuggestResponseSent' => __('Antwort gesendet. Der Bildvorschlag wird erneut geprüft.', 'meinweimar-core'),
'imageSuggestResponseRequired' => __('Bitte gib eine Antwort ein.', 'meinweimar-core'),
'accountSaving' => __('Profil wird gespeichert …', 'meinweimar-core'),
'accountSaved' => __('Profil gespeichert.', 'meinweimar-core'),
'accountError' => __('Das Profil konnte nicht gespeichert werden.', 'meinweimar-core'),
'accountFavoritesHeading' => __('Meine gespeicherten Inhalte', 'meinweimar-core'),
'accountFavoritesLoading' => __('Gespeicherte Inhalte werden geladen …', 'meinweimar-core'),
'accountFavoritesEmpty' => __('Du hast noch keine Inhalte gespeichert.', 'meinweimar-core'),
'accountFavoritesError' => __('Die gespeicherten Inhalte konnten nicht geladen werden.', 'meinweimar-core'),
'accountFavoritesOpen' => __('Inhalt öffnen', 'meinweimar-core'),
'favoriteSave' => __('Speichern', 'meinweimar-core'),
'favoriteRemove' => __('Aus gespeicherten entfernen', 'meinweimar-core'),
'favoriteSaving' => __('Speicherung wird aktualisiert …', 'meinweimar-core'),
'favoriteSaved' => __('In deinen gespeicherten Inhalten abgelegt.', 'meinweimar-core'),
'favoriteRemoved' => __('Aus deinen gespeicherten Inhalten entfernt.', 'meinweimar-core'),
'favoriteError' => __('Die Speicherung konnte nicht aktualisiert werden.', 'meinweimar-core'),
],
]);
}
public static function enqueue_assets(): void
{
wp_enqueue_style('meinweimar-frontend');
wp_enqueue_script('meinweimar-frontend');
wp_enqueue_script('meinweimar-google-maps');
}
public static function brand_markup(array $attributes = []): string
{
self::enqueue_assets();
$config = Settings::public_config();
$requested_wordmark = trim((string) ($attributes['wordmark'] ?? ''));
$wordmark = $requested_wordmark ?: $config['brand']['wordmark'];
$variant = 'dark' === ($attributes['variant'] ?? '') ? 'dark' : 'light';
$logo_config = $config['brand']['logo_' . $variant] ?? [];
if (empty($logo_config['url'])) {
$logo_config = $config['brand']['logo_light'] ?? [
'url' => $config['brand']['logo_url'],
'srcset' => $config['brand']['logo_srcset'],
'alt' => $config['brand']['logo_alt'],
];
}
$logo = (string) ($logo_config['url'] ?? '');
$logo_srcset = (string) ($logo_config['srcset'] ?? '');
$logo_alt = (string) ($logo_config['alt'] ?? '');
$brand_style = sprintf(
'--mw-primary:%s;--mw-secondary:%s;',
esc_attr($config['brand']['primary_color']),
esc_attr($config['brand']['secondary_color'])
);
ob_start();
?>
<a class="mw-brand mw-brand--<?php echo esc_attr($variant); ?>" style="<?php echo esc_attr($brand_style); ?>" href="<?php echo esc_url(home_url('/')); ?>" aria-label="<?php echo esc_attr($wordmark); ?>">
<?php if ($logo) : ?>
<img class="mw-brand__logo" src="<?php echo esc_url($logo); ?>"<?php if ($logo_srcset) : ?> srcset="<?php echo esc_attr($logo_srcset); ?>" sizes="(max-width: 640px) 160px, 240px"<?php endif; ?> alt="<?php echo esc_attr($logo_alt ?: $wordmark); ?>">
<?php endif; ?>
<span class="mw-brand__wordmark"><?php echo esc_html($wordmark); ?></span>
</a>
<?php
return (string) ob_get_clean();
}
public static function account_markup(array $attributes = []): string
{
self::enqueue_assets();
$config = Settings::public_config();
$brand_style = sprintf(
'--mw-primary:%s;--mw-secondary:%s;',
esc_attr($config['brand']['primary_color']),
esc_attr($config['brand']['secondary_color'])
);
$return_url = wp_get_referer() ?: home_url('/');
$favorites_heading_id = function_exists('wp_unique_id') ? wp_unique_id('mw-account-favorites-') : 'mw-account-favorites';
ob_start();
?>
<section class="mw-account" style="<?php echo esc_attr($brand_style); ?>" data-mw-account>
<?php if (!is_user_logged_in()) : ?>
<div class="mw-account__gate">
<p class="mw-account__eyebrow"><?php esc_html_e('MeinWeimar', 'meinweimar-core'); ?></p>
<h2><?php esc_html_e('Mein Konto', 'meinweimar-core'); ?></h2>
<p><?php esc_html_e('Melde dich an, um dein Profil, deine Favoriten und deine eigenen Aktivitäten zu verwalten.', 'meinweimar-core'); ?></p>
<a class="mw-account__primary" href="<?php echo esc_url(wp_login_url($return_url)); ?>"><?php esc_html_e('Anmelden', 'meinweimar-core'); ?></a>
<?php if (get_option('users_can_register')) : ?>
<a class="mw-account__secondary" href="<?php echo esc_url(wp_registration_url()); ?>"><?php esc_html_e('Konto erstellen', 'meinweimar-core'); ?></a>
<?php endif; ?>
</div>
<?php else :
$profile_response = AccountApi::profile_data(get_current_user_id(), true);
$profile = method_exists($profile_response, 'get_data') ? (array) $profile_response->get_data() : [];
$avatar_url = (string) ($profile['avatar_url'] ?? '');
$available_locales = array_values(array_filter(array_map('sanitize_text_field', (array) ($config['locales'] ?? []))));
$profile_locale = in_array((string) ($profile['locale'] ?? ''), $available_locales, true) ? (string) $profile['locale'] : '';
?>
<div class="mw-account__header">
<?php if ($avatar_url) : ?><img class="mw-account__avatar" src="<?php echo esc_url($avatar_url); ?>" alt="<?php echo esc_attr((string) ($profile['nickname'] ?? '')); ?>"><?php endif; ?>
<div>
<p class="mw-account__eyebrow"><?php esc_html_e('MeinWeimar', 'meinweimar-core'); ?></p>
<h2><?php esc_html_e('Mein Konto', 'meinweimar-core'); ?></h2>
<p class="mw-account__identity"><?php echo esc_html((string) ($profile['nickname'] ?? $profile['display_name'] ?? '')); ?></p>
</div>
</div>
<form class="mw-account__form" data-mw-account-form>
<div class="mw-account__grid">
<label><?php esc_html_e('Anzeigename', 'meinweimar-core'); ?><input type="text" name="nickname" maxlength="80" value="<?php echo esc_attr((string) ($profile['nickname'] ?? '')); ?>"></label>
<label><?php esc_html_e('Status', 'meinweimar-core'); ?><input type="text" name="status" maxlength="160" value="<?php echo esc_attr((string) ($profile['status'] ?? '')); ?>"></label>
<label><?php esc_html_e('Alter', 'meinweimar-core'); ?><input type="number" name="age" min="13" max="120" value="<?php echo esc_attr((string) ($profile['age'] ?? '')); ?>"></label>
<label><?php esc_html_e('Geschlecht', 'meinweimar-core'); ?><input type="text" name="gender" maxlength="40" value="<?php echo esc_attr((string) ($profile['gender'] ?? '')); ?>"></label>
<label><?php esc_html_e('Bevorzugte Sprache', 'meinweimar-core'); ?><select name="locale"><option value=""><?php esc_html_e('Systemstandard', 'meinweimar-core'); ?></option><?php foreach ($available_locales as $locale) : ?><option value="<?php echo esc_attr($locale); ?>" <?php selected($profile_locale, $locale); ?>><?php echo esc_html($locale); ?></option><?php endforeach; ?></select></label>
<label><?php esc_html_e('Profil-Sichtbarkeit', 'meinweimar-core'); ?><select name="profile_visibility"><option value="public" <?php selected((string) ($profile['profile_visibility'] ?? 'public'), 'public'); ?>><?php esc_html_e('Öffentlich', 'meinweimar-core'); ?></option><option value="private" <?php selected((string) ($profile['profile_visibility'] ?? 'public'), 'private'); ?>><?php esc_html_e('Privat', 'meinweimar-core'); ?></option></select></label>
<label><?php esc_html_e('Profilbild-Sichtbarkeit', 'meinweimar-core'); ?><select name="avatar_visibility"><option value="public" <?php selected((string) ($profile['avatar_visibility'] ?? 'private'), 'public'); ?>><?php esc_html_e('Öffentlich', 'meinweimar-core'); ?></option><option value="private" <?php selected((string) ($profile['avatar_visibility'] ?? 'private'), 'private'); ?>><?php esc_html_e('Privat', 'meinweimar-core'); ?></option></select></label>
</div>
<label><?php esc_html_e('Über mich', 'meinweimar-core'); ?><textarea name="bio" maxlength="1000" rows="5"><?php echo esc_textarea((string) ($profile['bio'] ?? '')); ?></textarea></label>
<button class="mw-account__primary" type="submit"><?php esc_html_e('Profil speichern', 'meinweimar-core'); ?></button>
<p class="mw-account__status" data-mw-account-status role="status" aria-live="polite"></p>
</form>
<section class="mw-account__section" data-mw-account-favorites aria-labelledby="<?php echo esc_attr($favorites_heading_id); ?>">
<h3 id="<?php echo esc_attr($favorites_heading_id); ?>"><?php esc_html_e('Meine gespeicherten Inhalte', 'meinweimar-core'); ?></h3>
<p class="mw-account__status" data-mw-account-favorites-status role="status" aria-live="polite"></p>
<div class="mw-account__saved-list" data-mw-account-favorites-list></div>
</section>
<p class="mw-account__footer"><a href="<?php echo esc_url(wp_logout_url($return_url)); ?>"><?php esc_html_e('Abmelden', 'meinweimar-core'); ?></a></p>
<?php endif; ?>
</section>
<?php
return (string) ob_get_clean();
}
public static function discovery_markup(array $attributes = []): string
{
self::enqueue_assets();
$available_types = array_keys(ContentTypes::public_post_types());
$geo_types = ContentTypes::geo_routes();
$requested_type = (string) ($attributes['type'] ?? 'places');
$type = 'all' === $requested_type || in_array($requested_type, $available_types, true) ? $requested_type : 'places';
$map_enabled = 'all' === $type || in_array($type, $geo_types, true);
$per_page = max(1, min(50, absint($attributes['per_page'] ?? 12)));
$default_search = sanitize_text_field((string) ($attributes['search'] ?? ''));
$default_category = sanitize_title((string) ($attributes['category'] ?? ''));
// A mixed discovery surface cannot safely infer which CPT owns a
// shortcode-provided category slug. Category selection on that
// surface is therefore explicit and route-scoped in the UI.
$effective_default_category = 'all' === $type ? '' : $default_category;
$default_region = sanitize_title((string) ($attributes['region'] ?? ''));
$allowed_sorts = ['auto', 'distance', 'newest', 'title'];
$requested_sort = (string) ($attributes['sort'] ?? 'auto');
$default_sort = in_array($requested_sort, $allowed_sorts, true) ? $requested_sort : 'auto';
$config = Settings::public_config();
$default_radius = round(max(0.01, min(50, (float) ($attributes['radius_km'] ?? ($config['geo']['default_radius_km'] ?? 10)))), 2);
$brand_style = sprintf(
'--mw-primary:%s;--mw-secondary:%s;',
esc_attr($config['brand']['primary_color']),
esc_attr($config['brand']['secondary_color'])
);
$search_id = function_exists('wp_unique_id') ? wp_unique_id('mw-discovery-search-') : 'mw-discovery-search';
$sort_id = function_exists('wp_unique_id') ? wp_unique_id('mw-discovery-sort-') : 'mw-discovery-sort';
$category_id = function_exists('wp_unique_id') ? wp_unique_id('mw-discovery-category-') : 'mw-discovery-category';
$region_id = function_exists('wp_unique_id') ? wp_unique_id('mw-discovery-region-') : 'mw-discovery-region';
$radius_id = function_exists('wp_unique_id') ? wp_unique_id('mw-discovery-radius-') : 'mw-discovery-radius';
$address_id = function_exists('wp_unique_id') ? wp_unique_id('mw-discovery-address-') : 'mw-discovery-address';
$address_results_id = function_exists('wp_unique_id') ? wp_unique_id('mw-discovery-address-results-') : 'mw-discovery-address-results';
$category_options = self::discovery_category_options($type, 'all' === $type ? $geo_types : $available_types);
$region_options = self::discovery_region_options();
$schema_filters = self::discovery_schema_filters($type, 'all' === $type ? $geo_types : $available_types, (array) ($config['content_schemas'] ?? []));
$dedicated_listing_route = in_array($type, ['classifieds', 'jobs', 'real-estate'], true);
ob_start();
?>
<section class="mw-discovery" style="<?php echo esc_attr($brand_style); ?>" data-mw-discovery data-type="<?php echo esc_attr($type); ?>" data-map-enabled="<?php echo $map_enabled ? 'true' : 'false'; ?>" data-per-page="<?php echo esc_attr((string) $per_page); ?>" data-default-search="<?php echo esc_attr($default_search); ?>" data-default-category="<?php echo esc_attr($effective_default_category); ?>" data-default-region="<?php echo esc_attr($default_region); ?>" data-default-sort="<?php echo esc_attr($default_sort); ?>" data-default-radius="<?php echo esc_attr((string) $default_radius); ?>">
<div class="mw-discovery__toolbar">
<label class="screen-reader-text" for="<?php echo esc_attr($search_id); ?>"><?php esc_html_e('Suche', 'meinweimar-core'); ?></label>
<input id="<?php echo esc_attr($search_id); ?>" class="mw-discovery__search" type="search" value="<?php echo esc_attr($default_search); ?>" placeholder="<?php esc_attr_e('In der Nähe suchen …', 'meinweimar-core'); ?>">
<button type="button" class="mw-discovery__submit"><?php esc_html_e('Suchen', 'meinweimar-core'); ?></button>
<label class="screen-reader-text" for="<?php echo esc_attr($sort_id); ?>"><?php esc_html_e('Sortierung', 'meinweimar-core'); ?></label>
<select id="<?php echo esc_attr($sort_id); ?>" class="mw-discovery__sort" data-sort>
<option value="auto" <?php selected($default_sort, 'auto'); ?>><?php esc_html_e('Automatisch', 'meinweimar-core'); ?></option>
<option value="distance" <?php selected($default_sort, 'distance'); ?>><?php esc_html_e('Entfernung', 'meinweimar-core'); ?></option>
<option value="newest" <?php selected($default_sort, 'newest'); ?>><?php esc_html_e('Neueste', 'meinweimar-core'); ?></option>
<option value="title" <?php selected($default_sort, 'title'); ?>><?php esc_html_e('Alphabetisch', 'meinweimar-core'); ?></option>
</select>
<label class="screen-reader-text" for="<?php echo esc_attr($category_id); ?>"><?php esc_html_e('Kategorie', 'meinweimar-core'); ?></label>
<select id="<?php echo esc_attr($category_id); ?>" class="mw-discovery__filter<?php echo $dedicated_listing_route ? ' mw-discovery__native-select' : ''; ?>" data-category>
<option value=""><?php esc_html_e('Alle Kategorien', 'meinweimar-core'); ?></option>
<?php if ($effective_default_category && !array_filter($category_options, static fn (array $option): bool => $option['value'] === $effective_default_category)) : ?>
<option value="<?php echo esc_attr($effective_default_category); ?>" selected><?php echo esc_html($effective_default_category); ?></option>
<?php endif; ?>
<?php foreach ($category_options as $option) : ?>
<option value="<?php echo esc_attr($option['value']); ?>" data-category-route="<?php echo esc_attr((string) ($option['route'] ?? '')); ?>" <?php selected($effective_default_category, $option['value']); ?>><?php echo esc_html($option['label']); ?></option>
<?php endforeach; ?>
</select>
<?php if ($dedicated_listing_route && $category_options) : ?>
<div class="mw-discovery__category-choices" role="group" aria-label="<?php esc_attr_e('Kategorie', 'meinweimar-core'); ?>">
<button type="button" class="mw-discovery__choice mw-discovery__choice--all<?php echo '' === $effective_default_category ? ' is-active' : ''; ?>" data-category-choice="" aria-pressed="<?php echo '' === $effective_default_category ? 'true' : 'false'; ?>"><?php esc_html_e('Alle', 'meinweimar-core'); ?></button>
<?php foreach ($category_options as $option) : ?>
<button type="button" class="mw-discovery__choice<?php echo $effective_default_category === $option['value'] ? ' is-active' : ''; ?>" data-category-choice="<?php echo esc_attr($option['value']); ?>" aria-pressed="<?php echo $effective_default_category === $option['value'] ? 'true' : 'false'; ?>"><?php echo esc_html($option['label']); ?></button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($map_enabled) : ?>
<label class="screen-reader-text" for="<?php echo esc_attr($region_id); ?>"><?php esc_html_e('Region', 'meinweimar-core'); ?></label>
<select id="<?php echo esc_attr($region_id); ?>" class="mw-discovery__filter" data-region>
<option value=""><?php esc_html_e('Alle Regionen', 'meinweimar-core'); ?></option>
<?php if ($default_region && !array_filter($region_options, static fn (array $option): bool => $option['value'] === $default_region)) : ?>
<option value="<?php echo esc_attr($default_region); ?>" selected><?php echo esc_html($default_region); ?></option>
<?php endif; ?>
<?php foreach ($region_options as $option) : ?>
<option value="<?php echo esc_attr($option['value']); ?>" <?php selected($default_region, $option['value']); ?>><?php echo esc_html($option['label']); ?></option>
<?php endforeach; ?>
</select>
<div class="mw-discovery__radius-control">
<label for="<?php echo esc_attr($radius_id); ?>"><?php esc_html_e('Radius', 'meinweimar-core'); ?></label>
<input id="<?php echo esc_attr($radius_id); ?>" class="mw-discovery__radius" type="range" min="0.01" max="50" step="0.01" value="<?php echo esc_attr((string) $default_radius); ?>" data-radius aria-valuemin="0.01" aria-valuemax="50">
<output data-radius-label for="<?php echo esc_attr($radius_id); ?>"><?php echo esc_html($default_radius < 1 ? round($default_radius * 1000) . ' ' . __('m', 'meinweimar-core') : rtrim(rtrim(number_format($default_radius, 2, '.', ''), '0'), '.') . ' ' . __('km', 'meinweimar-core')); ?></output>
</div>
<button type="button" class="mw-discovery__locate" data-geolocate aria-pressed="false"><?php esc_html_e('Meinen Standort verwenden', 'meinweimar-core'); ?></button>
<button type="button" class="mw-discovery__mode is-active" data-mode="list"><?php esc_html_e('Liste', 'meinweimar-core'); ?></button>
<button type="button" class="mw-discovery__mode" data-mode="map"><?php esc_html_e('Karte', 'meinweimar-core'); ?></button>
<?php endif; ?>
</div>
<?php if ($map_enabled) : ?>
<div class="mw-discovery__address-control">
<label class="screen-reader-text" for="<?php echo esc_attr($address_id); ?>"><?php esc_html_e('Adresse suchen', 'meinweimar-core'); ?></label>
<input id="<?php echo esc_attr($address_id); ?>" class="mw-discovery__address" type="search" autocomplete="street-address" aria-autocomplete="list" aria-controls="<?php echo esc_attr($address_results_id); ?>" placeholder="<?php esc_attr_e('Adresse oder Ort eingeben …', 'meinweimar-core'); ?>" data-address-search>
<button type="button" class="mw-discovery__address-submit" data-address-submit><?php esc_html_e('Adresse suchen', 'meinweimar-core'); ?></button>
<div id="<?php echo esc_attr($address_results_id); ?>" class="mw-discovery__address-results" data-address-results role="listbox" hidden></div>
</div>
<?php endif; ?>
<?php if ($schema_filters) : ?>
<div class="mw-discovery__schema-filters" data-schema-filters>
<?php foreach ($schema_filters as $filter) : ?>
<?php
$filter_id = function_exists('wp_unique_id') ? wp_unique_id('mw-discovery-schema-filter-') : 'mw-discovery-schema-filter-' . sanitize_key((string) $filter['key']);
$field_key = (string) $filter['key'];
$field_input = (string) ($filter['input'] ?? 'select');
$category_conditions = array_values(array_filter(array_map('sanitize_title', (array) ($filter['category_conditions'] ?? []))));
$field_conditions = array_values((array) ($filter['conditions'] ?? []));
?>
<?php $is_transaction_choice = 'real-estate' === $type && 'transaction_type' === $field_key && 'select' === $field_input; ?>
<div class="mw-discovery__schema-filter<?php echo 'range' === $field_input ? ' mw-discovery__schema-filter--range' : ''; ?><?php echo $is_transaction_choice ? ' mw-discovery__schema-filter--segments' : ''; ?>" data-schema-filter-wrapper data-schema-filter-key="<?php echo esc_attr($field_key); ?>" data-schema-filter-categories="<?php echo esc_attr((string) wp_json_encode($category_conditions)); ?>" data-schema-filter-conditions="<?php echo esc_attr((string) wp_json_encode($field_conditions)); ?>">
<span><?php echo esc_html((string) $filter['label']); ?></span>
<?php if ('range' === $field_input) : ?>
<div class="mw-discovery__schema-range" role="group" aria-label="<?php echo esc_attr((string) $filter['label']); ?>">
<label class="screen-reader-text" for="<?php echo esc_attr($filter_id . '-min'); ?>"><?php esc_html_e('Von', 'meinweimar-core'); ?></label>
<input id="<?php echo esc_attr($filter_id . '-min'); ?>" type="number" inputmode="decimal" data-schema-filter data-schema-filter-bound="min" data-[REDACTED] placeholder="<?php esc_attr_e('Von', 'meinweimar-core'); ?>"<?php echo !empty($filter['unit']) ? ' aria-label="' . esc_attr(sprintf(__('Von (%s)', 'meinweimar-core'), (string) $filter['unit'])) . '"' : ''; ?>>
<span aria-hidden="true">–</span>
<label class="screen-reader-text" for="<?php echo esc_attr($filter_id . '-max'); ?>"><?php esc_html_e('Bis', 'meinweimar-core'); ?></label>
<input id="<?php echo esc_attr($filter_id . '-max'); ?>" type="number" inputmode="decimal" data-schema-filter data-schema-filter-bound="max" data-[REDACTED] placeholder="<?php esc_attr_e('Bis', 'meinweimar-core'); ?>"<?php echo !empty($filter['unit']) ? ' aria-label="' . esc_attr(sprintf(__('Bis (%s)', 'meinweimar-core'), (string) $filter['unit'])) . '"' : ''; ?>>
</div>
<?php elseif ('date' === $field_input) : ?>
<input id="<?php echo esc_attr($filter_id); ?>" type="date" data-schema-filter data-[REDACTED]>
<?php else : ?>
<select id="<?php echo esc_attr($filter_id); ?>" class="<?php echo $is_transaction_choice ? 'mw-discovery__native-select' : ''; ?>" data-schema-filter data-[REDACTED]>
<option value=""><?php esc_html_e('Alle', 'meinweimar-core'); ?></option>
<?php foreach ((array) ($filter['values'] ?? []) as $value) : ?>
<option value="<?php echo esc_attr((string) $value); ?>"><?php echo esc_html(self::schema_filter_value_label((string) $value, $field_key)); ?></option>
<?php endforeach; ?>
</select>
<?php if ($is_transaction_choice) : ?>
<div class="mw-discovery__segment-group" role="group" aria-label="<?php echo esc_attr((string) $filter['label']); ?>">
<button type="button" class="mw-discovery__segment is-active" data-schema-choice-value="" aria-pressed="true"><?php esc_html_e('Alle', 'meinweimar-core'); ?></button>
<?php foreach ((array) ($filter['values'] ?? []) as $value) : ?>
<button type="button" class="mw-discovery__segment" data-schema-choice-value="<?php echo esc_attr((string) $value); ?>" aria-pressed="false"><?php echo esc_html(self::schema_filter_value_label((string) $value, $field_key)); ?></button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="mw-discovery__status" role="status" aria-live="polite"></div>
<div class="mw-discovery__body">
<div class="mw-discovery__list" data-results></div>
<?php if ($map_enabled) : ?>
<div class="mw-discovery__map" data-map hidden>
<div class="mw-discovery__map-heading"><?php esc_html_e('Kartenübersicht', 'meinweimar-core'); ?></div>
<div class="mw-discovery__google-map" data-google-map role="region" aria-label="<?php esc_attr_e('Google-Karte', 'meinweimar-core'); ?>"></div>
<div class="mw-discovery__markers" data-markers></div>
<div class="mw-discovery__map-preview" data-map-preview hidden></div>
<p class="mw-discovery__map-note"><?php esc_html_e('Der Kartenadapter wird an dieser gemeinsamen Geo-Schnittstelle angebunden.', 'meinweimar-core'); ?></p>
</div>
<?php endif; ?>
</div>
</section>
<?php
return (string) ob_get_clean();
}
private static function discovery_category_options(string $type, array $available_types): array
{
if (!function_exists('get_terms')) {
return [];
}
$routes = 'all' === $type ? $available_types : [$type];
$options = [];
foreach ($routes as $route) {
$definition = ContentTypes::definitions()[$route] ?? null;
$taxonomy = is_array($definition) ? (string) ($definition['category'] ?? '') : '';
if (!$taxonomy || !taxonomy_exists($taxonomy)) {
continue;
}
$terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => true, 'number' => 100, 'orderby' => 'name', 'order' => 'ASC']);
if (is_wp_error($terms)) {
continue;
}
foreach ((array) $terms as $term) {
$value = sanitize_title((string) ($term->slug ?? ''));
if (!$value) {
continue;
}
$option_key = 'all' === $type ? $route . ':' . $value : $value;
$label = (string) ($term->name ?? $value);
if ('all' === $type && is_array($definition)) {
$label = sprintf('%s: %s', (string) $definition['label'], $label);
}
$options[$option_key] = ['value' => $value, 'label' => $label, 'route' => $route];
}
}
return array_values($options);
}
/**
* Build discovery controls from the public FieldSchemas projection. Enum,
* boolean, date and numeric range filters all use the shared REST query
* contract; range bounds are represented as <api_key>_min/max controls.
*
* @param array<string, mixed> $schemas
* @return list<array<string, mixed>>
*/
private static function discovery_schema_filters(string $type, array $available_types, array $schemas): array
{
// Route-specific fields must never be merged into one cross-CPT
// control. The mixed discovery endpoint still supports shared search,
// category, region and geo parameters; typed filters belong to the
// dedicated route surface where their FieldSchemas constraints are
// unambiguous.
if ('all' === $type) {
return [];
}
$routes = 'all' === $type ? $available_types : [$type];
$filters = [];
foreach ($routes as $route) {
foreach ((array) ($schemas[$route] ?? []) as $schema_key => $field) {
if (!is_array($field) || empty($field['filter']['enabled'])) {
continue;
}
$kind = (string) ($field['filter']['kind'] ?? '');
if (!in_array($kind, ['enum', 'boolean', 'date', 'range'], true)) {
continue;
}
if ('date' === $kind && 'date' !== (string) ($field['input'] ?? '')) {
continue;
}
$api_key = (string) preg_replace('/^[^.]+\./', '', (string) $schema_key);
if ('' === $api_key) {
continue;
}
if (!isset($filters[$api_key])) {
$filters[$api_key] = [
'key' => $api_key,
'input' => 'range' === $kind
? 'range'
: ('date' === ($field['input'] ?? '') ? 'date' : 'select'),
'values' => [],
'unit' => $field['unit'] ?? null,
'filter_kind' => $kind,
'category_conditions' => [],
'conditions' => [],
'label' => self::schema_filter_label($api_key),
];
}
$filters[$api_key]['values'] = array_values(array_unique(array_merge(
(array) $filters[$api_key]['values'],
'boolean' === $kind ? ['1', '0'] : (array) ($field['values'] ?? [])
)));
$filters[$api_key]['category_conditions'] = array_values(array_unique(array_merge(
(array) $filters[$api_key]['category_conditions'],
(array) ($field['category_conditions'] ?? [])
)));
foreach ((array) ($field['conditions'] ?? []) as $condition) {
if (is_array($condition) && !in_array($condition, $filters[$api_key]['conditions'], true)) {
$filters[$api_key]['conditions'][] = $condition;
}
}
}
}
return array_values($filters);
}
private static function schema_filter_label(string $key): string
{
$labels = [
'offer_type' => __('Angebotsart', 'meinweimar-core'),
'provider_type' => __('Anbieter', 'meinweimar-core'),
'price_type' => __('Preisangabe', 'meinweimar-core'),
'price_interval' => __('Preisintervall', 'meinweimar-core'),
'transaction_type' => __('Vermarktungsart', 'meinweimar-core'),
'commission_applicable' => __('Provision', 'meinweimar-core'),
'heating_costs_included' => __('Heizkosten in Nebenkosten enthalten', 'meinweimar-core'),
'condition' => __('Zustand', 'meinweimar-core'),
'delivery_mode' => __('Übergabe / Versand', 'meinweimar-core'),
'employment_type' => __('Beschäftigungsart', 'meinweimar-core'),
'work_mode' => __('Arbeitsmodell', 'meinweimar-core'),
'featured' => __('Hervorgehoben', 'meinweimar-core'),
'start' => __('Beginn', 'meinweimar-core'),
'end' => __('Ende', 'meinweimar-core'),
'available_from' => __('Verfügbar ab', 'meinweimar-core'),
];
return $labels[$key] ?? __('Weitere Filter', 'meinweimar-core');
}
private static function schema_filter_value_label(string $value, string $key = ''): string
{
if ('ticket_mode' === $key) {
return match ($value) {
'free' => __('Kostenlos', 'meinweimar-core'),
'external' => __('Externe Buchung', 'meinweimar-core'),
'internal' => __('Interne Buchung', 'meinweimar-core'),
'registration' => __('Anmeldung erforderlich', 'meinweimar-core'),
default => __('Weitere', 'meinweimar-core'),
};
}
return match ($value) {
'1' => __('Ja', 'meinweimar-core'),
'0' => __('Nein', 'meinweimar-core'),
'offer' => __('Angebot', 'meinweimar-core'),
'search' => __('Gesuch', 'meinweimar-core'),
'employment' => __('Stelle', 'meinweimar-core'),
'rent' => __('Miete', 'meinweimar-core'),
'sale' => __('Kaufen', 'meinweimar-core'),
'lease' => __('Miete', 'meinweimar-core'),
'temporary' => __('Miete', 'meinweimar-core'),
'private' => __('Privat', 'meinweimar-core'),
'business' => __('Gewerblich', 'meinweimar-core'),
'organization' => __('Organisation', 'meinweimar-core'),
'broker' => __('Makler', 'meinweimar-core'),
'employer' => __('Arbeitgeber', 'meinweimar-core'),
'fixed' => __('Festpreis', 'meinweimar-core'),
'negotiable' => __('Verhandelbar', 'meinweimar-core'),
'free' => __('Zu verschenken', 'meinweimar-core'),
'on_request' => __('Auf Anfrage', 'meinweimar-core'),
'range' => __('Spanne', 'meinweimar-core'),
'once' => __('Einmalig', 'meinweimar-core'),
'hour' => __('Pro Stunde', 'meinweimar-core'),
'month' => __('Pro Monat', 'meinweimar-core'),
'year' => __('Pro Jahr', 'meinweimar-core'),
'yes' => __('Provisionspflichtig', 'meinweimar-core'),
'no' => __('Provisionsfrei', 'meinweimar-core'),
'percentage' => __('Prozentsatz', 'meinweimar-core'),
'monthly_rent' => __('Monatliche Kaltmieten', 'meinweimar-core'),
'included' => __('Enthalten', 'meinweimar-core'),
'excluded' => __('Nicht enthalten', 'meinweimar-core'),
'new' => __('Neu', 'meinweimar-core'),
'used' => __('Gebraucht', 'meinweimar-core'),
'defective' => __('Defekt', 'meinweimar-core'),
'pickup' => __('Abholung', 'meinweimar-core'),
'shipping' => __('Versand', 'meinweimar-core'),
'both' => __('Abholung oder Versand', 'meinweimar-core'),
'digital' => __('Digital', 'meinweimar-core'),
'none' => __('Keine Angabe', 'meinweimar-core'),
'full_time' => __('Vollzeit', 'meinweimar-core'),
'part_time' => __('Teilzeit', 'meinweimar-core'),
'freelance' => __('Freiberuflich', 'meinweimar-core'),
'apprenticeship' => __('Ausbildung', 'meinweimar-core'),
'internship' => __('Praktikum', 'meinweimar-core'),
'onsite' => __('Vor Ort', 'meinweimar-core'),
'hybrid' => __('Hybrid', 'meinweimar-core'),
'remote' => __('Fernarbeit', 'meinweimar-core'),
'scheduled' => __('Geplant', 'meinweimar-core'),
'cancelled' => __('Abgesagt', 'meinweimar-core'),
'postponed' => __('Verschoben', 'meinweimar-core'),
'expired' => __('Abgelaufen', 'meinweimar-core'),
'EUR' => __('Euro', 'meinweimar-core'),
'USD' => __('US-Dollar', 'meinweimar-core'),
'GBP' => __('Britische Pfund', 'meinweimar-core'),
default => __('Weitere', 'meinweimar-core'),
};
}
private static function discovery_region_options(): array
{
if (!function_exists('get_terms') || !taxonomy_exists('mw_region')) {
return [];
}
$terms = get_terms(['taxonomy' => 'mw_region', 'hide_empty' => true, 'number' => 100, 'orderby' => 'name', 'order' => 'ASC']);
if (is_wp_error($terms)) {
return [];
}
$options = [];
foreach ((array) $terms as $term) {
$value = sanitize_title((string) ($term->slug ?? ''));
if ($value) {
$options[$value] = ['value' => $value, 'label' => (string) ($term->name ?? $value)];
}
}
return array_values($options);
}
public static function shell_markup(array $attributes = []): string
{
self::enqueue_assets();
$brand = self::brand_markup();
ob_start();
?>
<header class="mw-shell">
<div class="mw-shell__inner">
<?php echo $brand; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php echo self::navigation_markup(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</header>
<?php
return (string) ob_get_clean();
}
public static function navigation_markup(): string
{
self::enqueue_assets();
$navigation = Settings::public_config()['navigation'];
$navigation_id = wp_unique_id('mw-primary-navigation-');
ob_start();
?>
<nav class="mw-nav" aria-label="<?php esc_attr_e('Hauptnavigation', 'meinweimar-core'); ?>">
<button class="mw-nav__toggle" type="button" aria-expanded="false" aria-controls="<?php echo esc_attr($navigation_id); ?>"><?php esc_html_e('Menü', 'meinweimar-core'); ?></button>
<ul id="<?php echo esc_attr($navigation_id); ?>" class="mw-nav__list">
<?php foreach ($navigation as $item) : ?>
<li class="mw-nav__item"><a href="<?php echo esc_url(self::route_url((string) $item['route'])); ?>"><?php echo esc_html($item['label']); ?></a></li>
<?php endforeach; ?>
</ul>
</nav>
<?php
return (string) ob_get_clean();
}
private static function route_url(string $route): string
{
if (preg_match('#^https?://#i', $route)) {
return $route;
}
return home_url('/' . ltrim($route, '/'));
}
}
