<?php
declare(strict_types=1);
namespace MeinWeimar;
final class Settings
{
public const OPTION = 'meinweimar_core_settings';
private const HERO_COPY_DEFAULTS_OPTION = 'meinweimar_core_hero_copy_defaults_version';
private const HERO_COPY_DEFAULTS_VERSION = '1';
private const HERO_COPY_KEYS = [
'hero_eyebrow',
'hero_title',
'hero_body',
'hero_primary_label',
'hero_primary_url',
'hero_secondary_label',
'hero_secondary_url',
];
public static function hooks(): void
{
add_action('admin_menu', [self::class, 'admin_menu']);
add_action('admin_init', [self::class, 'maybe_seed_hero_copy_defaults'], 1);
add_action('admin_init', [self::class, 'register']);
add_action('admin_enqueue_scripts', [self::class, 'enqueue_admin_assets']);
}
public static function defaults(): array
{
return [
'brand_logo_id' => 0,
'brand_logo_dark_id' => 0,
'navigation_center_logo_id' => 0,
'brand_hero_image_id' => 0,
'brand_wordmark' => 'MeinWeimar',
'primary_color' => '#ff1e6e',
'secondary_color' => '#20242e',
'accent_color' => '#ff1e6e',
'ink_color' => '#20242e',
'surface_color' => '#ffffff',
'muted_color' => '#64748b',
'brand_surface_color' => '#ffe5ee',
'success_color' => '#159570',
'warning_color' => '#b7791f',
'hero_eyebrow' => [],
'hero_title' => [
'de_DE' => 'Entdecke Weimar',
'en_US' => 'Discover Weimar',
],
'hero_body' => [
'de_DE' => 'Finde Orte, Events und Empfehlungen rund um Weimar.',
'en_US' => 'Find places, events and recommendations around Weimar.',
],
'hero_primary_label' => [
'de_DE' => 'Jetzt entdecken',
'en_US' => 'Start exploring',
],
'hero_primary_url' => [
'de_DE' => '#discover',
'en_US' => '#discover',
],
'hero_secondary_label' => [],
'hero_secondary_url' => [],
'hero_logo_id' => 0,
'hero_icon_id' => 0,
'hero_campaigns' => [],
// One shared surface-hero contract for every public module. The
// app and Website consume the same localized copy/media/colors;
// no module may invent its own presentation source.
'surface_heroes' => self::surface_hero_defaults(),
'home_sections' => self::home_section_defaults(),
'hero_height_mode' => 'content',
'hero_image_fit' => 'cover',
'hero_title_color' => '#ffffff',
'hero_body_color' => '#ffffff',
'hero_eyebrow_color' => '',
'hero_button_color' => '',
'hero_button_text_color' => '#ffffff',
'hero_secondary_button_color' => '',
'hero_secondary_button_text_color' => '',
'default_radius_km' => 10.0,
'default_map_zoom' => 13,
'geo_default_latitude' => 50.9795,
'geo_default_longitude' => 11.3235,
// A provider must be explicitly configured before either the
// Website or the app requests map tiles.
'geo_tiles_enabled' => false,
'geo_tile_url' => '',
'geo_tile_styles' => [
'road' => '',
'light' => '',
'dark' => '',
'satellite' => '',
],
'geo_routing_url' => '',
'geo_routing_profile' => 'driving',
// External navigation is intentionally keyless: Google Maps URLs
// hand off to the installed app and fall back to the browser.
'geo_navigation_provider' => 'google_maps',
'geo_external_navigation_url' => '',
// The Website JS API key is never included in the public REST
// config. It is localized only for the frontend map adapter and
// may alternatively come from wp-config.
'geo_google_maps_api_key' => '',
'geo_geocoding_enabled' => false,
'geo_geocoding_url' => '',
'geo_attribution' => '',
'geo_user_agent_contact' => '',
'geo_fallback_marker_id' => 0,
'geo_user_marker_id' => 0,
'geo_resource_markers' => [
'places' => 0,
'events' => 0,
'services' => 0,
'classifieds' => 0,
'jobs' => 0,
'real-estate' => 0,
],
'locales' => ['de_DE', 'en_US'],
'default_locale' => 'de_DE',
'fallback_locale' => 'de_DE',
'navigation_surfaces_configured' => false,
'navigation_bottom' => [],
'navigation_fullscreen' => [],
'navigation_icons' => [],
'resource_icons' => [],
'field_icons' => [],
'app_texts' => [],
'navigation' => [
['id' => 'start', 'label' => 'Start', 'label_key' => 'navStart', 'icon' => 'explore', 'route' => '/', 'enabled' => true, 'primary' => true],
['id' => 'map', 'label' => 'Karte', 'label_key' => 'navMap', 'icon' => 'map', 'route' => '/karte/', 'enabled' => true, 'primary' => true],
['id' => 'events', 'label' => 'Events', 'label_key' => 'navEvents', 'icon' => 'event', 'route' => '/events/', 'enabled' => true, 'primary' => true],
['id' => 'saved', 'label' => 'Gespeichert', 'label_key' => 'navSaved', 'icon' => 'bookmark', 'route' => '/gespeichert/', 'enabled' => true, 'primary' => true],
['id' => 'community', 'label' => 'Community', 'label_key' => 'navCommunity', 'icon' => 'people', 'route' => '/community/', 'enabled' => true, 'primary' => false],
['id' => 'services', 'label' => 'Services und Einrichtungen', 'label_key' => 'moduleServices', 'icon' => 'account_balance', 'route' => '/services/', 'enabled' => true, 'primary' => false],
['id' => 'classifieds', 'label' => 'Kleinanzeigen', 'label_key' => 'moduleClassifieds', 'icon' => 'sell', 'route' => '/kleinanzeigen/', 'enabled' => true, 'primary' => false],
['id' => 'jobs', 'label' => 'Jobs', 'label_key' => 'moduleJobs', 'icon' => 'work', 'route' => '/jobs/', 'enabled' => true, 'primary' => false],
['id' => 'real-estate', 'label' => 'Immobilien', 'label_key' => 'moduleRealEstate', 'icon' => 'home_work', 'route' => '/immobilien/', 'enabled' => true, 'primary' => false],
['id' => 'news', 'label' => 'News', 'label_key' => 'moduleNews', 'icon' => 'article', 'route' => '/news/', 'enabled' => true, 'primary' => false],
['id' => 'guides', 'label' => 'Ratgeber', 'label_key' => 'moduleGuides', 'icon' => 'menu_book', 'route' => '/guides/', 'enabled' => true, 'primary' => false],
],
];
}
public static function install_defaults(): void
{
if (false === get_option(self::OPTION, false)) {
add_option(self::OPTION, self::defaults(), '', false);
}
}
public static function get(): array
{
$settings = wp_parse_args((array) get_option(self::OPTION, []), self::defaults());
// The previous plugin default was the light-gray app canvas. Treat
// that untouched legacy default as the new white canvas, while still
// leaving every other explicitly chosen color under admin control.
if ('#f5f6f8' === strtolower((string) ($settings['surface_color'] ?? ''))) {
$settings['surface_color'] = '#ffffff';
}
return $settings;
}
/**
* Seed the initial Hero copy into the settings UI once on a controlled
* admin request. Public API requests never write configuration options.
*/
public static function maybe_seed_hero_copy_defaults(): void
{
if (self::HERO_COPY_DEFAULTS_VERSION === (string) get_option(self::HERO_COPY_DEFAULTS_OPTION, '')) {
return;
}
$stored = get_option(self::OPTION, null);
if (!is_array($stored)) {
return;
}
$defaults = self::defaults();
$changed = false;
foreach (self::HERO_COPY_KEYS as $key) {
if (!empty($stored[$key]) || empty($defaults[$key])) {
continue;
}
$stored[$key] = $defaults[$key];
$changed = true;
}
if ($changed) {
update_option(self::OPTION, $stored, false);
}
update_option(self::HERO_COPY_DEFAULTS_OPTION, self::HERO_COPY_DEFAULTS_VERSION, false);
}
public static function sanitize_locale($value): string
{
if (!is_scalar($value)) {
return '';
}
$candidate = str_replace('-', '_', preg_replace('/[^A-Za-z0-9_-]/', '', (string) $value));
return self::resolve_locale($candidate, self::normalize_locales(self::get()['locales'] ?? []), '');
}
public static function google_maps_frontend_config(): array
{
$settings = self::get();
$api_key = trim((string) ($settings['geo_google_maps_api_key'] ?? ''));
if ('' === $api_key && defined('MEINWEIMAR_GOOGLE_MAPS_API_KEY')) {
$api_key = trim((string) constant('MEINWEIMAR_GOOGLE_MAPS_API_KEY'));
}
return [
'enabled' => '' !== $api_key,
'api_key' => $api_key,
];
}
public static function public_config(): array
{
$settings = self::get();
$hero_copy = self::hero_copy($settings);
$logo_light = self::logo_config(absint($settings['brand_logo_id']));
$logo_dark = self::logo_config(absint($settings['brand_logo_dark_id'] ?? 0));
$navigation_center_logo = self::logo_config(absint($settings['navigation_center_logo_id'] ?? 0));
$hero_image = self::logo_config(absint($settings['brand_hero_image_id'] ?? 0));
$hero_logo = self::logo_config(absint($settings['hero_logo_id'] ?? 0));
$hero_icon = self::logo_config(absint($settings['hero_icon_id'] ?? 0));
$hero = [
'eyebrow' => $hero_copy['hero_eyebrow'],
'title' => $hero_copy['hero_title'],
'body' => $hero_copy['hero_body'],
'primary_label' => $hero_copy['hero_primary_label'],
'primary_url' => $hero_copy['hero_primary_url'],
'secondary_label' => $hero_copy['hero_secondary_label'],
'secondary_url' => $hero_copy['hero_secondary_url'],
'image' => $hero_image,
'image_url' => $hero_image['url'],
'image_app_url' => $hero_image['app_url'],
'logo' => $hero_logo,
'icon' => $hero_icon,
'height_mode' => self::hero_height_mode($settings['hero_height_mode'] ?? 'content'),
'image_fit' => self::hero_image_fit($settings['hero_image_fit'] ?? 'cover'),
'colors' => self::hero_colors($settings),
'campaign' => null,
];
$active_campaign = self::active_hero_campaign($settings);
if (null !== $active_campaign) {
$hero['eyebrow'] = self::merge_localized_values($hero['eyebrow'], $active_campaign['eyebrow']);
$hero['title'] = self::merge_localized_values($hero['title'], $active_campaign['title']);
$hero['body'] = self::merge_localized_values($hero['body'], $active_campaign['body']);
$hero['primary_label'] = self::merge_localized_values($hero['primary_label'], $active_campaign['primary_label']);
$hero['primary_url'] = self::merge_localized_values($hero['primary_url'], $active_campaign['primary_url']);
$hero['secondary_label'] = self::merge_localized_values($hero['secondary_label'], $active_campaign['secondary_label']);
$hero['secondary_url'] = self::merge_localized_values($hero['secondary_url'], $active_campaign['secondary_url']);
$campaign_image = $active_campaign['image_id']
? self::logo_config($active_campaign['image_id'])
: $hero_image;
$campaign_logo = $active_campaign['logo_id']
? self::logo_config($active_campaign['logo_id'])
: $hero_logo;
$campaign_icon = $active_campaign['icon_id']
? self::logo_config($active_campaign['icon_id'])
: $hero_icon;
$hero['image'] = $campaign_image;
$hero['image_url'] = $campaign_image['url'];
$hero['image_app_url'] = $campaign_image['app_url'];
$hero['logo'] = $campaign_logo;
$hero['icon'] = $campaign_icon;
$hero['height_mode'] = 'inherit' === $active_campaign['height_mode']
? $hero['height_mode']
: $active_campaign['height_mode'];
$hero['image_fit'] = 'inherit' === $active_campaign['image_fit']
? $hero['image_fit']
: $active_campaign['image_fit'];
$hero['colors'] = self::merge_hero_colors($hero['colors'], $active_campaign['colors']);
$hero['campaign'] = [
'id' => $active_campaign['id'],
'label' => $active_campaign['label'],
'start_at' => $active_campaign['start_at'],
'end_at' => $active_campaign['end_at'],
'priority' => $active_campaign['priority'],
];
}
$locales = self::normalize_locales($settings['locales']);
$default_locale = sanitize_text_field((string) ($settings['default_locale'] ?? 'de_DE'));
$fallback_locale = sanitize_text_field((string) ($settings['fallback_locale'] ?? 'de_DE'));
$default_locale = in_array($default_locale, $locales, true) ? $default_locale : $locales[0];
$fallback_locale = in_array($fallback_locale, $locales, true) ? $fallback_locale : $locales[0];
// Legacy provider-neutral values remain stored for compatibility but
// are no longer projected to clients. Only the future, explicitly
// approved Google Maps Tiles/Session adapter may populate this part
// of the public contract.
$tiles_enabled = false;
$tile_url = '';
$routing_url = '';
// Google Maps is the product decision for external navigation. The
// old selectable provider value is intentionally ignored in the
// public contract; local maps are only the platform fallback.
$navigation_provider = 'google_maps';
$external_navigation_url = '';
$geocoding_enabled = false;
$geocoding_url = '';
$tile_styles = [];
$attribution = '';
$user_agent_contact = '';
$map_configured = false;
$legacy_navigation = self::normalize_navigation($settings['navigation']);
$bottom_navigation = self::normalize_navigation(
!empty($settings['navigation_bottom']) ? $settings['navigation_bottom'] : $legacy_navigation
);
$fullscreen_navigation = self::normalize_navigation(
!empty($settings['navigation_fullscreen']) ? $settings['navigation_fullscreen'] : $legacy_navigation
);
$navigation_icons = self::sanitize_image_map($settings['navigation_icons'] ?? []);
$resource_icons = self::sanitize_image_map($settings['resource_icons'] ?? []);
$field_icons = self::sanitize_image_map($settings['field_icons'] ?? []);
$bottom_navigation = self::public_navigation($bottom_navigation, $navigation_icons);
$fullscreen_navigation = self::public_navigation($fullscreen_navigation, $navigation_icons);
return [
'brand' => [
'wordmark' => sanitize_text_field((string) $settings['brand_wordmark']),
// Keep the original keys as the light-surface default for existing clients.
'logo_url' => $logo_light['url'],
'logo_srcset' => $logo_light['srcset'],
'logo_alt' => $logo_light['alt'],
'logo_light' => $logo_light,
'logo_dark' => $logo_dark,
'navigation_logo' => $navigation_center_logo,
'hero_image' => $hero_image,
'hero_image_url' => $hero_image['url'],
'hero_image_app_url' => $hero_image['app_url'],
'primary_color' => self::color_value('primary_color', $settings['primary_color'] ?? '', '#ff1e6e'),
'secondary_color' => self::color_value('secondary_color', $settings['secondary_color'] ?? '', '#20242e'),
],
'colors' => self::public_colors($settings),
'hero' => $hero,
'home_sections' => self::public_home_sections($settings['home_sections'] ?? []),
'surface_heroes' => self::public_surface_heroes($settings['surface_heroes'] ?? []),
'geo' => [
'default_radius_km' => self::decimal($settings['default_radius_km'] ?? 10, 0.01, 50, 2),
'default_map_zoom' => max(1, min(20, (int) $settings['default_map_zoom'])),
'default_latitude' => self::decimal($settings['geo_default_latitude'] ?? 50.9795, -90, 90, 6),
'default_longitude' => self::decimal($settings['geo_default_longitude'] ?? 11.3235, -180, 180, 6),
'data_basis' => 'wordpress-geo-config',
// Expose the actual provider state. Do not report a provider
// as configured while the operator has not supplied a usable
// tile source; no fallback provider is invented here.
'provider' => $map_configured ? 'configured' : 'unconfigured',
'map_configured' => $map_configured,
'tile_url_template' => $tile_url,
'tile_styles' => $tile_styles,
'routing_url' => $routing_url,
'routing_profile' => sanitize_key((string) ($settings['geo_routing_profile'] ?? 'driving')) ?: 'driving',
'navigation_provider' => $navigation_provider,
'external_navigation_url_template' => $external_navigation_url,
'geocoding_url' => $geocoding_url,
'attribution' => $attribution,
'user_agent_contact' => $user_agent_contact,
'capabilities' => [
'tiles' => $map_configured,
'routing' => '' !== $routing_url,
'geocoding' => $geocoding_enabled && '' !== $geocoding_url,
],
'markers' => MapMarkers::public_config(),
],
'locales' => $locales,
'localization' => [
'available_locales' => $locales,
'default_locale' => $default_locale,
'fallback_locale' => $fallback_locale,
'ui_source' => 'wordpress-api-with-client-fallback',
'content_source' => 'wordpress-api',
],
'app_texts' => self::normalize_app_texts($settings['app_texts'] ?? []),
// Clients use this to select the correct field groups and render
// only fields supported by the current content type. It contains
// no post-meta names or legacy-only implementation details.
'content_schemas' => FieldSchemas::public_contract(),
// Category profiles are emitted from the same FieldSchemas
// registry. Clients must not recreate category/field rules.
'content_categories' => FieldSchemas::category_contract(),
'navigation' => array_values(array_filter($legacy_navigation, static fn (array $item): bool => $item['enabled'])),
'app_navigation_configured' => !empty($settings['navigation_surfaces_configured']),
'app_navigation' => [
'bottom' => array_values(array_filter($bottom_navigation, static fn (array $item): bool => $item['enabled'])),
'fullscreen' => array_values(array_filter($fullscreen_navigation, static fn (array $item): bool => $item['enabled'])),
],
'resource_icons' => self::public_image_map($resource_icons),
// Visual field icons are deliberately separate from FieldSchemas:
// FieldSchemas owns data/validation, while this map only controls
// the optional media used when a fixed field is rendered.
'field_icons' => self::public_image_map($field_icons),
];
}
public static function admin_menu(): void
{
add_options_page(
__('MeinWeimar', 'meinweimar-core'),
__('MeinWeimar', 'meinweimar-core'),
'manage_options',
'meinweimar-core',
[self::class, 'render_page']
);
}
public static function register(): void
{
register_setting('meinweimar_core', self::OPTION, [
'type' => 'array',
'sanitize_callback' => [self::class, 'sanitize'],
'default' => self::defaults(),
]);
add_settings_section(
'meinweimar_core_brand',
__('Globale Marke und Plattformwerte', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('Diese Werte werden später von Website, Elementor und API-Clients gemeinsam verwendet.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
add_settings_field('brand_logo_id', __('Logo für helle Hintergründe', 'meinweimar-core'), [self::class, 'logo_field'], 'meinweimar-core', 'meinweimar_core_brand', ['variant' => 'light']);
add_settings_field('brand_logo_dark_id', __('Logo für dunkle Hintergründe', 'meinweimar-core'), [self::class, 'logo_field'], 'meinweimar-core', 'meinweimar_core_brand', ['variant' => 'dark']);
add_settings_field('brand_hero_image_id', __('Headerbild für die Home', 'meinweimar-core'), [self::class, 'image_field'], 'meinweimar-core', 'meinweimar_core_brand');
add_settings_field('brand_wordmark', __('Wordmark', 'meinweimar-core'), [self::class, 'text_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'brand_wordmark']);
add_settings_field('primary_color', __('Primärfarbe', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'primary_color']);
add_settings_field('secondary_color', __('Sekundärfarbe', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'secondary_color']);
add_settings_field('accent_color', __('Alternative Akzentfarbe', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'accent_color']);
add_settings_field('ink_color', __('Anthrazit / dunkle Flächen', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'ink_color']);
add_settings_field('surface_color', __('App-Hintergrund', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'surface_color']);
add_settings_field('muted_color', __('Sekundärtext', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'muted_color']);
add_settings_field('brand_surface_color', __('Helle Akzentfläche', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'brand_surface_color']);
add_settings_field('success_color', __('Erfolgsfarbe', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'success_color']);
add_settings_field('warning_color', __('Warnfarbe', 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'warning_color']);
add_settings_field('default_radius_km', __('Standardradius (km)', 'meinweimar-core'), [self::class, 'number_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'default_radius_km', 'min' => 0.01, 'max' => 50, 'step' => '0.01']);
add_settings_field('default_map_zoom', __('Standard-Kartenzoom', 'meinweimar-core'), [self::class, 'number_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'default_map_zoom', 'min' => 1, 'max' => 20]);
add_settings_field('default_locale', __('Standard-Locale', 'meinweimar-core'), [self::class, 'locale_select_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'default_locale']);
add_settings_field('fallback_locale', __('Fallback-Locale', 'meinweimar-core'), [self::class, 'locale_select_field'], 'meinweimar-core', 'meinweimar_core_brand', ['key' => 'fallback_locale']);
add_settings_field('locales', __('Verfügbare Locales', 'meinweimar-core'), [self::class, 'locales_field'], 'meinweimar-core', 'meinweimar_core_brand');
add_settings_section(
'meinweimar_core_hero',
__('Home-Hero und Einstiege', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('Texte, Medien und Links werden als lokalisierte Konfiguration an Website und App ausgegeben. Leere Locale-Werte fallen innerhalb der konfigurierten Hero-Texte zurück; nicht konfigurierte Hero-Texte werden nicht aus Flutter ergänzt.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
foreach (['hero_eyebrow' => 'Hero-Eyebrow', 'hero_title' => 'Hero-Titel', 'hero_body' => 'Hero-Text', 'hero_primary_label' => 'Primärbutton-Text', 'hero_primary_url' => 'Primärbutton-Link', 'hero_secondary_label' => 'Sekundärbutton-Text', 'hero_secondary_url' => 'Sekundärbutton-Link'] as $key => $label) {
add_settings_field($key, __($label, 'meinweimar-core'), [self::class, 'localized_text_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => $key, 'link' => str_ends_with($key, '_url')]);
}
add_settings_field('hero_height_mode', __('Hero-Höhenmodus', 'meinweimar-core'), [self::class, 'select_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => 'hero_height_mode', 'options' => ['content' => __('Inhaltsabhängig – Bild darf beschnitten werden', 'meinweimar-core'), 'image' => __('Bildproportion – gesamte Bildfläche verwenden', 'meinweimar-core')]]);
add_settings_field('hero_image_fit', __('Hero-Bilddarstellung', 'meinweimar-core'), [self::class, 'select_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => 'hero_image_fit', 'options' => ['cover' => __('Ausfüllen / Zuschneiden', 'meinweimar-core'), 'contain' => __('Vollständig anzeigen', 'meinweimar-core')]]);
foreach ([
'hero_title_color' => 'Hero-Titelfarbe',
'hero_body_color' => 'Hero-Textfarbe',
'hero_eyebrow_color' => 'Hero-Eyebrow-Farbe',
'hero_button_color' => 'Primärbutton-Farbe',
'hero_button_text_color' => 'Primärbutton-Textfarbe',
'hero_secondary_button_color' => 'Sekundärbutton-Farbe',
'hero_secondary_button_text_color' => 'Sekundärbutton-Textfarbe',
] as $key => $label) {
add_settings_field($key, __($label, 'meinweimar-core'), [self::class, 'color_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => $key]);
}
add_settings_field('hero_logo_id', __('Hero-Logo', 'meinweimar-core'), [self::class, 'media_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => 'hero_logo_id', 'slug' => 'hero-logo', 'label' => __('Hero-Logo aus Mediathek wählen', 'meinweimar-core')]);
add_settings_field('hero_icon_id', __('Hero-Icon', 'meinweimar-core'), [self::class, 'media_field'], 'meinweimar-core', 'meinweimar_core_hero', ['key' => 'hero_icon_id', 'slug' => 'hero-icon', 'label' => __('Hero-Icon aus Mediathek wählen', 'meinweimar-core')]);
add_settings_field('hero_campaigns', __('Weitere Hero-Kampagnen', 'meinweimar-core'), [self::class, 'hero_campaigns_field'], 'meinweimar-core', 'meinweimar_core_hero');
add_settings_section(
'meinweimar_core_home',
__('Home-Startseite und Sektionen', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('Ordne die sichtbaren Home-Sektionen per Drag-and-drop oder mit den Auf-/Ab-Schaltflächen. Überschriften und Icons werden zentral an Website und App ausgegeben.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
add_settings_field('home_sections', __('Home-Sektionen', 'meinweimar-core'), [self::class, 'home_sections_field'], 'meinweimar-core', 'meinweimar_core_home');
add_settings_section(
'meinweimar_core_surfaces',
__('Modul-Header und Hero-Flächen', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('Pflege Headerbild, Überschrift, Unterzeile und Farben der öffentlichen Modulflächen zentral. Die Werte werden lokalisiert an Website und App ausgegeben; leere Medienfelder verwenden den jeweiligen Inhalts-Fallback.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
add_settings_field('surface_heroes', __('Öffentliche Modul-Header', 'meinweimar-core'), [self::class, 'surface_heroes_field'], 'meinweimar-core', 'meinweimar_core_surfaces');
add_settings_section(
'meinweimar_core_app',
__('App-Menüs, Icons und Texte', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('Die Reihenfolge der JSON-Arrays ist die sichtbare Reihenfolge. Bottom-Menü und Vollbild-Menü werden getrennt an die App ausgegeben. Leere Icon-Felder verwenden die Flutter-Icons als Fallback.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
add_settings_field('navigation_center_logo_id', __('Center-Logo im Bottom-Menü', 'meinweimar-core'), [self::class, 'media_field'], 'meinweimar-core', 'meinweimar_core_app', ['key' => 'navigation_center_logo_id', 'slug' => 'navigation-center-logo', 'label' => __('Center-Logo aus Mediathek wählen', 'meinweimar-core')]);
add_settings_field('navigation_bottom', __('Bottom-Menü (JSON)', 'meinweimar-core'), [self::class, 'navigation_surface_field'], 'meinweimar-core', 'meinweimar_core_app', ['key' => 'navigation_bottom']);
add_settings_field('navigation_fullscreen', __('Vollbild-Menü (JSON)', 'meinweimar-core'), [self::class, 'navigation_surface_field'], 'meinweimar-core', 'meinweimar_core_app', ['key' => 'navigation_fullscreen']);
foreach (self::defaults()['navigation'] as $item) {
add_settings_field('navigation_icon_' . $item['id'], sprintf(__('%s – eigenes App-Icon', 'meinweimar-core'), $item['label']), [self::class, 'navigation_icon_field'], 'meinweimar-core', 'meinweimar_core_app', ['id' => $item['id'], 'label' => $item['label']]);
}
foreach (ContentTypes::definitions() as $route => $definition) {
add_settings_field('resource_icon_' . $route, sprintf(__('%s – eigenes CPT-Icon', 'meinweimar-core'), $definition['label']), [self::class, 'resource_icon_field'], 'meinweimar-core', 'meinweimar_core_app', ['route' => $route, 'label' => $definition['label']]);
}
foreach (self::field_icon_definitions() as $field => $label) {
add_settings_field('field_icon_' . $field, sprintf(__('%s – eigenes Feld-Icon', 'meinweimar-core'), $label), [self::class, 'field_icon_field'], 'meinweimar-core', 'meinweimar_core_app', ['field' => $field, 'label' => $label]);
}
add_settings_field('app_texts', __('Alle App-Texte (lokalisiertes JSON)', 'meinweimar-core'), [self::class, 'app_texts_field'], 'meinweimar-core', 'meinweimar_core_app');
add_settings_section(
'meinweimar_core_geo',
__('Google Maps und Standort', 'meinweimar-core'),
static function (): void {
echo '<p>' . esc_html__('MeinWeimar verwendet Google Maps für die Website-Karte und den externen Navigieren-Button. Die installierte Google-Maps-App wird zuerst geöffnet; fehlt sie, übernimmt die lokale Karten-App, danach der Browser-Link. Der Browser-Key muss auf deine Website-Domain eingeschränkt sein.', 'meinweimar-core') . '</p>';
},
'meinweimar-core'
);
add_settings_field('geo_google_maps_status', __('Google-Maps-Status', 'meinweimar-core'), [self::class, 'google_maps_status_field'], 'meinweimar-core', 'meinweimar_core_geo');
add_settings_field('geo_google_maps_api_key', __('Google-Maps-Platform-Key (Website)', 'meinweimar-core'), [self::class, 'google_maps_api_key_field'], 'meinweimar-core', 'meinweimar_core_geo');
add_settings_field('geo_default_latitude', __('Standardzentrum Breitengrad', 'meinweimar-core'), [self::class, 'number_field'], 'meinweimar-core', 'meinweimar_core_geo', ['key' => 'geo_default_latitude', 'min' => -90, 'max' => 90, 'step' => '0.000001']);
add_settings_field('geo_default_longitude', __('Standardzentrum Längengrad', 'meinweimar-core'), [self::class, 'number_field'], 'meinweimar-core', 'meinweimar_core_geo', ['key' => 'geo_default_longitude', 'min' => -180, 'max' => 180, 'step' => '0.000001']);
add_settings_field('geo_fallback_marker_id', __('Globaler Fallback-Marker', 'meinweimar-core'), [self::class, 'marker_field'], 'meinweimar-core', 'meinweimar_core_geo', ['key' => 'geo_fallback_marker_id', 'label' => __('Globaler Fallback-Marker', 'meinweimar-core')]);
add_settings_field('geo_user_marker_id', __('GPS-Standortmarker', 'meinweimar-core'), [self::class, 'marker_field'], 'meinweimar-core', 'meinweimar_core_geo', ['user' => true, 'label' => __('GPS-Standortmarker', 'meinweimar-core')]);
foreach (ContentTypes::definitions() as $route => $definition) {
if (empty($definition['geo'])) {
continue;
}
add_settings_field(
'geo_resource_marker_' . $route,
sprintf(__('%s-Marker', 'meinweimar-core'), $definition['label']),
[self::class, 'marker_field'],
'meinweimar-core',
'meinweimar_core_geo',
['route' => $route, 'label' => sprintf(__('%s-Marker', 'meinweimar-core'), $definition['label'])]
);
}
}
public static function sanitize($input): array
{
$input = is_array($input) ? $input : [];
$defaults = self::defaults();
$brand_logo_id = self::image_id($input['brand_logo_id'] ?? $defaults['brand_logo_id']);
$brand_logo_dark_id = self::image_id($input['brand_logo_dark_id'] ?? $defaults['brand_logo_dark_id']);
$navigation_center_logo_id = self::image_id($input['navigation_center_logo_id'] ?? $defaults['navigation_center_logo_id']);
$brand_hero_image_id = self::image_id($input['brand_hero_image_id'] ?? $defaults['brand_hero_image_id']);
$hero_logo_id = self::image_id($input['hero_logo_id'] ?? $defaults['hero_logo_id']);
$hero_icon_id = self::image_id($input['hero_icon_id'] ?? $defaults['hero_icon_id']);
$locales = self::normalize_locales($input['locales_json'] ?? $defaults['locales']);
$navigation_surface_input = array_key_exists('navigation_bottom_json', $input) || array_key_exists('navigation_fullscreen_json', $input) || array_key_exists('navigation_bottom', $input) || array_key_exists('navigation_fullscreen', $input);
return [
'brand_logo_id' => $brand_logo_id,
'brand_logo_dark_id' => $brand_logo_dark_id,
'navigation_center_logo_id' => $navigation_center_logo_id,
'brand_hero_image_id' => $brand_hero_image_id,
'brand_wordmark' => sanitize_text_field((string) ($input['brand_wordmark'] ?? $defaults['brand_wordmark'])),
'primary_color' => self::color_value('primary_color', $input['primary_color'] ?? '', $defaults['primary_color']),
'secondary_color' => self::color_value('secondary_color', $input['secondary_color'] ?? '', $defaults['secondary_color']),
'accent_color' => self::color_value('accent_color', $input['accent_color'] ?? '', $defaults['accent_color']),
'ink_color' => self::color_value('ink_color', $input['ink_color'] ?? '', $defaults['ink_color']),
'surface_color' => self::color_value('surface_color', $input['surface_color'] ?? '', $defaults['surface_color']),
'muted_color' => self::color_value('muted_color', $input['muted_color'] ?? '', $defaults['muted_color']),
'brand_surface_color' => self::color_value('brand_surface_color', $input['brand_surface_color'] ?? '', $defaults['brand_surface_color']),
'success_color' => self::color_value('success_color', $input['success_color'] ?? '', $defaults['success_color']),
'warning_color' => self::color_value('warning_color', $input['warning_color'] ?? '', $defaults['warning_color']),
'hero_eyebrow' => self::normalize_localized_text($input['hero_eyebrow_json'] ?? $input['hero_eyebrow'] ?? $defaults['hero_eyebrow']),
'hero_title' => self::normalize_localized_text($input['hero_title_json'] ?? $input['hero_title'] ?? $defaults['hero_title']),
'hero_body' => self::normalize_localized_text($input['hero_body_json'] ?? $input['hero_body'] ?? $defaults['hero_body']),
'hero_primary_label' => self::normalize_localized_text($input['hero_primary_label_json'] ?? $input['hero_primary_label'] ?? $defaults['hero_primary_label']),
'hero_primary_url' => self::normalize_localized_links($input['hero_primary_url_json'] ?? $input['hero_primary_url'] ?? $defaults['hero_primary_url']),
'hero_secondary_label' => self::normalize_localized_text($input['hero_secondary_label_json'] ?? $input['hero_secondary_label'] ?? $defaults['hero_secondary_label']),
'hero_secondary_url' => self::normalize_localized_links($input['hero_secondary_url_json'] ?? $input['hero_secondary_url'] ?? $defaults['hero_secondary_url']),
'hero_logo_id' => $hero_logo_id,
'hero_icon_id' => $hero_icon_id,
'hero_campaigns' => self::sanitize_hero_campaigns($input['hero_campaigns'] ?? (self::get()['hero_campaigns'] ?? $defaults['hero_campaigns'])),
'home_sections' => self::normalize_home_sections(
array_key_exists('home_sections_json', $input)
? $input['home_sections_json']
: (self::get()['home_sections'] ?? $defaults['home_sections'])
),
'hero_height_mode' => self::hero_height_mode($input['hero_height_mode'] ?? $defaults['hero_height_mode']),
'hero_image_fit' => self::hero_image_fit($input['hero_image_fit'] ?? $defaults['hero_image_fit']),
'hero_title_color' => self::color_value('hero_title_color', $input['hero_title_color'] ?? '', $defaults['hero_title_color']),
'hero_body_color' => self::color_value('hero_body_color', $input['hero_body_color'] ?? '', $defaults['hero_body_color']),
'hero_eyebrow_color' => self::color_value('hero_eyebrow_color', $input['hero_eyebrow_color'] ?? '', $defaults['hero_eyebrow_color']),
'hero_button_color' => self::color_value('hero_button_color', $input['hero_button_color'] ?? '', $defaults['hero_button_color']),
'hero_button_text_color' => self::color_value('hero_button_text_color', $input['hero_button_text_color'] ?? '', $defaults['hero_button_text_color']),
'hero_secondary_button_color' => self::color_value('hero_secondary_button_color', $input['hero_secondary_button_color'] ?? '', $defaults['hero_secondary_button_color']),
'hero_secondary_button_text_color' => self::color_value('hero_secondary_button_text_color', $input['hero_secondary_button_text_color'] ?? '', $defaults['hero_secondary_button_text_color']),
'surface_heroes' => self::sanitize_surface_heroes(
$input['surface_heroes'] ?? (self::get()['surface_heroes'] ?? $defaults['surface_heroes'])
),
'default_radius_km' => self::decimal($input['default_radius_km'] ?? $defaults['default_radius_km'], 0.01, 50, 2),
'default_map_zoom' => max(1, min(20, absint($input['default_map_zoom'] ?? $defaults['default_map_zoom']))),
'geo_default_latitude' => self::decimal($input['geo_default_latitude'] ?? $defaults['geo_default_latitude'], -90, 90, 6),
'geo_default_longitude' => self::decimal($input['geo_default_longitude'] ?? $defaults['geo_default_longitude'], -180, 180, 6),
'geo_tiles_enabled' => !empty($input['geo_tiles_enabled']),
'geo_tile_url' => self::service_url($input['geo_tile_url'] ?? $defaults['geo_tile_url']),
'geo_tile_styles' => self::sanitize_tile_styles($input['geo_tile_styles_json'] ?? $input['geo_tile_styles'] ?? $defaults['geo_tile_styles']),
'geo_routing_url' => self::service_url($input['geo_routing_url'] ?? $defaults['geo_routing_url']),
'geo_routing_profile' => sanitize_key((string) ($input['geo_routing_profile'] ?? $defaults['geo_routing_profile'])) ?: $defaults['geo_routing_profile'],
'geo_navigation_provider' => self::navigation_provider($input['geo_navigation_provider'] ?? $defaults['geo_navigation_provider']),
'geo_external_navigation_url' => self::service_url($input['geo_external_navigation_url'] ?? $defaults['geo_external_navigation_url']),
'geo_google_maps_api_key' => self::google_maps_api_key_value(
array_key_exists('geo_google_maps_api_key', $input)
? $input['geo_google_maps_api_key']
: ($defaults['geo_google_maps_api_key'] ?? '')
),
'geo_geocoding_enabled' => !empty($input['geo_geocoding_enabled']),
'geo_geocoding_url' => self::service_url($input['geo_geocoding_url'] ?? $defaults['geo_geocoding_url']),
'geo_attribution' => sanitize_text_field((string) ($input['geo_attribution'] ?? $defaults['geo_attribution'])),
'geo_user_agent_contact' => sanitize_text_field((string) ($input['geo_user_agent_contact'] ?? $defaults['geo_user_agent_contact'])),
'geo_fallback_marker_id' => self::image_id($input['geo_fallback_marker_id'] ?? $defaults['geo_fallback_marker_id']),
'geo_user_marker_id' => self::image_id($input['geo_user_marker_id'] ?? $defaults['geo_user_marker_id']),
'geo_resource_markers' => self::sanitize_resource_markers($input['geo_resource_markers'] ?? $defaults['geo_resource_markers']),
'locales' => $locales,
'default_locale' => self::resolve_locale($input['default_locale'] ?? $defaults['default_locale'], $locales, $locales[0]),
'fallback_locale' => self::resolve_locale($input['fallback_locale'] ?? $defaults['fallback_locale'], $locales, $locales[0]),
'navigation_surfaces_configured' => $navigation_surface_input || !empty($input['navigation_surfaces_configured']),
'navigation_bottom' => self::normalize_navigation($input['navigation_bottom_json'] ?? $input['navigation_bottom'] ?? $input['navigation'] ?? $defaults['navigation']),
'navigation_fullscreen' => self::normalize_navigation($input['navigation_fullscreen_json'] ?? $input['navigation_fullscreen'] ?? $input['navigation'] ?? $defaults['navigation']),
'navigation_icons' => self::sanitize_image_map($input['navigation_icons'] ?? $defaults['navigation_icons']),
'resource_icons' => self::sanitize_image_map($input['resource_icons'] ?? $defaults['resource_icons']),
'field_icons' => self::sanitize_image_map($input['field_icons'] ?? $defaults['field_icons']),
'app_texts' => self::normalize_app_texts($input['app_texts_json'] ?? $input['app_texts'] ?? $defaults['app_texts']),
'navigation' => self::normalize_navigation($input['navigation_json'] ?? $input['navigation'] ?? $defaults['navigation']),
];
}
private static function render_settings_section(string $page, string $section_id): void
{
global $wp_settings_fields, $wp_settings_sections;
$section = $wp_settings_sections[$page][$section_id] ?? null;
if (!is_array($section)) {
return;
}
if (!empty($section['title'])) {
echo '<h2>' . wp_kses_post((string) $section['title']) . '</h2>';
}
if (!empty($section['callback']) && is_callable($section['callback'])) {
call_user_func($section['callback'], $section);
}
if (empty($wp_settings_fields[$page][$section_id])) {
return;
}
echo '<table class="form-table" role="presentation">';
do_settings_fields($page, $section_id);
echo '</table>';
}
public static function render_page(): void
{
if (!current_user_can('manage_options')) {
return;
}
$tabs = [
['id' => 'brand', 'label' => __('Marke und Farben', 'meinweimar-core'), 'section' => 'meinweimar_core_brand'],
['id' => 'hero', 'label' => __('Home-Hero', 'meinweimar-core'), 'section' => 'meinweimar_core_hero'],
['id' => 'home', 'label' => __('Home-Startseite', 'meinweimar-core'), 'section' => 'meinweimar_core_home'],
['id' => 'surfaces', 'label' => __('Modul-Header', 'meinweimar-core'), 'section' => 'meinweimar_core_surfaces'],
['id' => 'app', 'label' => __('App-Menüs und Texte', 'meinweimar-core'), 'section' => 'meinweimar_core_app'],
['id' => 'geo', 'label' => __('Karte und Standort', 'meinweimar-core'), 'section' => 'meinweimar_core_geo'],
];
echo '<div class="wrap mw-settings-page"><h1>' . esc_html__('MeinWeimar', 'meinweimar-core') . '</h1>';
echo '<p>' . esc_html__('Zentrale Konfiguration für Marke, Geo-Discovery und gemeinsame Clients.', 'meinweimar-core') . '</p>';
echo '<form method="post" action="options.php">';
settings_fields('meinweimar_core');
echo '<nav class="nav-tab-wrapper mw-settings-tabs" role="tablist" aria-label="' . esc_attr__('MeinWeimar-Einstellungen', 'meinweimar-core') . '">';
foreach ($tabs as $index => $tab) {
$tab_id = 'mw-tab-' . $tab['id'];
$panel_id = 'mw-panel-' . $tab['id'];
echo '<button type="button" class="nav-tab' . (0 === $index ? ' nav-tab-active' : '') . '" id="' . esc_attr($tab_id) . '" role="tab" aria-controls="' . esc_attr($panel_id) . '" aria-selected="' . (0 === $index ? 'true' : 'false') . '" data-mw-settings-tab="' . esc_attr($tab['id']) . '">' . esc_html($tab['label']) . '</button>';
}
echo '</nav>';
foreach ($tabs as $index => $tab) {
$panel_id = 'mw-panel-' . $tab['id'];
echo '<section class="mw-settings-panel" id="' . esc_attr($panel_id) . '" role="tabpanel" aria-labelledby="mw-tab-' . esc_attr($tab['id']) . '" data-mw-settings-panel="' . esc_attr($tab['id']) . '"' . (0 === $index ? '' : ' hidden') . '>';
self::render_settings_section('meinweimar-core', $tab['section']);
echo '</section>';
}
submit_button();
echo '</form></div>';
}
public static function logo_field(array $args = []): void
{
$variant = ('dark' === ($args['variant'] ?? '')) ? 'dark' : 'light';
$key = 'dark' === $variant ? 'brand_logo_dark_id' : 'brand_logo_id';
$slug = 'brand-logo-' . $variant;
$settings = self::get();
$value = absint($settings[$key] ?? 0);
$title = 'dark' === $variant
? __('MeinWeimar-Logo für dunkle Hintergründe auswählen', 'meinweimar-core')
: __('MeinWeimar-Logo für helle Hintergründe auswählen', 'meinweimar-core');
echo '<input type="hidden" id="mw-' . esc_attr($slug) . '-id" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '">';
echo '<button type="button" class="button" data-mw-logo-picker data-input="mw-' . esc_attr($slug) . '-id" data-preview="mw-' . esc_attr($slug) . '-preview" data-title="' . esc_attr($title) . '" data-button-text="' . esc_attr__('Logo verwenden', 'meinweimar-core') . '">' . esc_html__('Logo aus Mediathek wählen', 'meinweimar-core') . '</button>';
echo '<span id="mw-' . esc_attr($slug) . '-preview" style="margin-left:12px;">' . ($value ? esc_html__('Logo ausgewählt', 'meinweimar-core') : esc_html__('Noch kein Logo ausgewählt', 'meinweimar-core')) . '</span>';
}
public static function image_field(array $args = []): void
{
$key = 'brand_hero_image_id';
$slug = 'brand-hero-image';
$settings = self::get();
$value = absint($settings[$key] ?? 0);
echo '<input type="hidden" id="mw-' . esc_attr($slug) . '-id" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '">';
echo '<button type="button" class="button" data-mw-logo-picker data-input="mw-' . esc_attr($slug) . '-id" data-preview="mw-' . esc_attr($slug) . '-preview" data-title="' . esc_attr__('Headerbild für die MeinWeimar-Home auswählen', 'meinweimar-core') . '" data-button-text="' . esc_attr__('Headerbild verwenden', 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Headerbild ausgewählt', 'meinweimar-core') . '">' . esc_html__('Headerbild aus Mediathek wählen', 'meinweimar-core') . '</button>';
echo '<span id="mw-' . esc_attr($slug) . '-preview" style="margin-left:12px;">' . ($value ? esc_html__('Headerbild ausgewählt', 'meinweimar-core') : esc_html__('Noch kein Headerbild ausgewählt', 'meinweimar-core')) . '</span>';
}
public static function media_field(array $args): void
{
$key = sanitize_key((string) ($args['key'] ?? ''));
$slug = sanitize_key((string) ($args['slug'] ?? $key));
$settings = self::get();
$value = absint($settings[$key] ?? 0);
$label = (string) ($args['label'] ?? __('Bild aus Mediathek wählen', 'meinweimar-core'));
echo '<input type="hidden" id="mw-' . esc_attr($slug) . '-id" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '">';
echo '<button type="button" class="button" data-mw-logo-picker data-input="mw-' . esc_attr($slug) . '-id" data-preview="mw-' . esc_attr($slug) . '-preview" data-title="' . esc_attr($label) . '" data-button-text="' . esc_attr__('Auswählen', 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Ausgewählt', 'meinweimar-core') . '">' . esc_html($label) . '</button>';
echo '<span id="mw-' . esc_attr($slug) . '-preview" style="margin-left:12px;">' . ($value ? esc_html__('Ausgewählt', 'meinweimar-core') : esc_html__('Noch nichts ausgewählt', 'meinweimar-core')) . '</span>';
}
public static function localized_text_field(array $args): void
{
$key = sanitize_key((string) ($args['key'] ?? ''));
$settings = self::get();
$values = self::normalize_localized_text($settings[$key] ?? []);
$name = self::OPTION . '[' . $key . '_json]';
$description = !empty($args['link'])
? __('Erlaubt https/http, interne Pfade sowie mailto:, tel: und #-Anker. Leere Locale fällt zurück.', 'meinweimar-core')
: __('Objekt mit Locale-Schlüsseln, zum Beispiel {"de_DE":"…","en_US":"…"}.', 'meinweimar-core');
echo '<textarea class="large-text code" rows="3" name="' . esc_attr($name) . '">' . esc_textarea((string) wp_json_encode($values, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</textarea>';
echo '<p class="description">' . esc_html($description) . '</p>';
}
public static function hero_campaigns_field(): void
{
$campaigns = self::sanitize_hero_campaigns(self::get()['hero_campaigns'] ?? []);
echo '<div class="mw-hero-campaigns" data-mw-hero-campaigns>';
echo '<div class="mw-hero-campaigns__intro">';
echo '<p>' . esc_html__('Lege zeitgesteuerte Hero-Varianten für Kampagnen, bezahlte Platzierungen oder besondere Aktionen an. Der Standard-Hero bleibt der sichere Fallback.', 'meinweimar-core') . '</p>';
echo '<p class="description">' . esc_html__('Eine Kampagne wird nur verwendet, wenn sie aktiv ist und das optionale Start-/Enddatum passt. Bei mehreren passenden Kampagnen gewinnt die höchste Priorität; leere Kampagnenfelder übernehmen den Standard-Hero.', 'meinweimar-core') . '</p>';
echo '</div>';
echo '<input type="hidden" name="' . esc_attr(self::OPTION) . '[hero_campaigns][__empty]" value="1">';
echo '<div data-mw-hero-campaign-list>';
foreach ($campaigns as $index => $campaign) {
self::render_hero_campaign_card($campaign, (string) $index);
}
echo '</div>';
echo '<button type="button" class="button button-secondary mw-hero-campaigns__add" data-mw-hero-add>' . esc_html__('Weiteren Hero anlegen', 'meinweimar-core') . '</button>';
echo '<template data-mw-hero-template>';
self::render_hero_campaign_card([], '__INDEX__');
echo '</template>';
echo '</div>';
}
private static function render_hero_campaign_card(array $campaign, string $index): void
{
$campaign = self::hero_campaign_defaults($campaign, $index);
$prefix = self::OPTION . '[hero_campaigns][' . $index . ']';
$slug = 'hero-campaign-' . sanitize_key($index);
echo '<article class="mw-hero-campaign" data-mw-hero-campaign-card>';
echo '<div class="mw-hero-campaign__header">';
echo '<div><strong>' . esc_html__('Hero-Kampagne', 'meinweimar-core') . '</strong><span class="mw-hero-campaign__status" data-mw-hero-status></span></div>';
echo '<button type="button" class="button-link-delete" data-mw-hero-remove>' . esc_html__('Löschen', 'meinweimar-core') . '</button>';
echo '</div>';
echo '<input type="hidden" name="' . esc_attr($prefix . '[id]') . '" value="' . esc_attr($campaign['id']) . '">';
echo '<div class="mw-hero-campaign__meta">';
echo '<label><span>' . esc_html__('Interner Name', 'meinweimar-core') . '</span><input type="text" class="regular-text" name="' . esc_attr($prefix . '[label]') . '" value="' . esc_attr($campaign['label']) . '" placeholder="' . esc_attr__('z. B. Zwiebelmarkt', 'meinweimar-core') . '"></label>';
echo '<label class="mw-hero-campaign__active"><span>' . esc_html__('Aktiv', 'meinweimar-core') . '</span><input type="checkbox" name="' . esc_attr($prefix . '[active]') . '" value="1"' . checked($campaign['active'], true, false) . ' data-mw-hero-active></label>';
echo '<label><span>' . esc_html__('Priorität', 'meinweimar-core') . '</span><input type="number" name="' . esc_attr($prefix . '[priority]') . '" value="' . esc_attr((string) $campaign['priority']) . '" min="0" max="100" step="1"></label>';
echo '<label><span>' . esc_html__('Startdatum optional', 'meinweimar-core') . '</span><input type="date" name="' . esc_attr($prefix . '[start_at]') . '" value="' . esc_attr($campaign['start_at']) . '"></label>';
echo '<label><span>' . esc_html__('Enddatum optional', 'meinweimar-core') . '</span><input type="date" name="' . esc_attr($prefix . '[end_at]') . '" value="' . esc_attr($campaign['end_at']) . '"></label>';
echo '<label><span>' . esc_html__('Höhenmodus', 'meinweimar-core') . '</span><select name="' . esc_attr($prefix . '[height_mode]') . '"><option value="inherit"' . selected($campaign['height_mode'], 'inherit', false) . '>' . esc_html__('Standard verwenden', 'meinweimar-core') . '</option><option value="content"' . selected($campaign['height_mode'], 'content', false) . '>' . esc_html__('Inhaltsabhängig', 'meinweimar-core') . '</option><option value="image"' . selected($campaign['height_mode'], 'image', false) . '>' . esc_html__('Bildproportion', 'meinweimar-core') . '</option></select></label>';
echo '<label><span>' . esc_html__('Bilddarstellung', 'meinweimar-core') . '</span><select name="' . esc_attr($prefix . '[image_fit]') . '"><option value="inherit"' . selected($campaign['image_fit'], 'inherit', false) . '>' . esc_html__('Standard verwenden', 'meinweimar-core') . '</option><option value="cover"' . selected($campaign['image_fit'], 'cover', false) . '>' . esc_html__('Ausfüllen / Zuschneiden', 'meinweimar-core') . '</option><option value="contain"' . selected($campaign['image_fit'], 'contain', false) . '>' . esc_html__('Vollständig anzeigen', 'meinweimar-core') . '</option></select></label>';
echo '</div>';
echo '<div class="mw-hero-campaign__copy">';
foreach ([
'eyebrow' => ['Hero-Eyebrow', false],
'title' => ['Hero-Titel', false],
'body' => ['Hero-Text', false],
'primary_label' => ['Primärbutton-Text', false],
'primary_url' => ['Primärbutton-Link', true],
'secondary_label' => ['Sekundärbutton-Text', false],
'secondary_url' => ['Sekundärbutton-Link', true],
] as $key => [$label, $link]) {
$values = $campaign[$key] ?? [];
echo '<label><span>' . esc_html__($label, 'meinweimar-core') . '</span><textarea class="large-text code" rows="2" name="' . esc_attr($prefix . '[' . $key . '_json]') . '" placeholder="' . esc_attr__('Leer = Standard-Hero übernehmen', 'meinweimar-core') . '">' . esc_textarea((string) wp_json_encode($values, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</textarea><small>' . esc_html($link ? __('Erlaubt https/http, Pfade, mailto:, tel: und #-Anker.', 'meinweimar-core') : __('Locale-Objekt, z. B. {"de_DE":"…","en_US":"…"}.', 'meinweimar-core')) . '</small></label>';
}
echo '</div>';
echo '<div class="mw-hero-campaign__colors">';
foreach ([
'title' => 'Hero-Titelfarbe',
'body' => 'Hero-Textfarbe',
'eyebrow' => 'Hero-Eyebrow-Farbe',
'button' => 'Primärbutton-Farbe',
'button_text' => 'Primärbutton-Textfarbe',
'secondary_button' => 'Sekundärbutton-Farbe',
'secondary_button_text' => 'Sekundärbutton-Textfarbe',
] as $key => $label) {
self::hero_campaign_color_field($prefix, $key, $label, (string) ($campaign['colors'][$key] ?? ''));
}
echo '</div>';
echo '<div class="mw-hero-campaign__media">';
self::hero_campaign_media_field($prefix, $slug . '-image', 'image_id', absint($campaign['image_id']), __('Hero-Bild aus Mediathek wählen', 'meinweimar-core'));
self::hero_campaign_media_field($prefix, $slug . '-logo', 'logo_id', absint($campaign['logo_id']), __('Hero-Logo aus Mediathek wählen', 'meinweimar-core'));
self::hero_campaign_media_field($prefix, $slug . '-icon', 'icon_id', absint($campaign['icon_id']), __('Hero-Icon aus Mediathek wählen', 'meinweimar-core'));
echo '</div>';
echo '</article>';
}
private static function hero_campaign_media_field(string $prefix, string $slug, string $key, int $value, string $label): void
{
$input_id = 'mw-' . sanitize_key($slug) . '-id';
$preview_id = $input_id . '-preview';
echo '<label><span>' . esc_html($label) . '</span><input type="hidden" id="' . esc_attr($input_id) . '" name="' . esc_attr($prefix . '[' . $key . ']') . '" value="' . esc_attr((string) $value) . '"><button type="button" class="button" data-mw-logo-picker data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($preview_id) . '" data-title="' . esc_attr($label) . '" data-button-text="' . esc_attr__('Auswählen', 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Ausgewählt', 'meinweimar-core') . '">' . esc_html__('Auswählen', 'meinweimar-core') . '</button> <span id="' . esc_attr($preview_id) . '">' . ($value ? esc_html__('Ausgewählt', 'meinweimar-core') : esc_html__('Standard verwenden', 'meinweimar-core')) . '</span></label>';
}
private static function hero_campaign_color_field(string $prefix, string $key, string $label, string $value): void
{
$input_key = 'colors][' . $key;
$enabled_key = $prefix . '[colors][' . $key . '_enabled]';
$color_key = $prefix . '[' . $input_key . ']';
$input_id = 'mw-hero-color-' . sanitize_key($key) . '-' . substr(md5($prefix), 0, 8);
echo '<label><span>' . esc_html__($label, 'meinweimar-core') . '</span><span class="mw-hero-campaign__color-control"><input type="hidden" name="' . esc_attr($enabled_key) . '" value="0"><input type="checkbox" name="' . esc_attr($enabled_key) . '" value="1"' . checked($value !== '', true, false) . '> ' . esc_html__('Eigene Farbe', 'meinweimar-core') . '</span><input type="color" id="' . esc_attr($input_id) . '" name="' . esc_attr($color_key) . '" value="' . esc_attr($value ?: '#ffffff') . '"></label>';
}
/**
* Shared editable header configuration for public content surfaces.
* This deliberately sits beside the Home Hero contract rather than in a
* second content-field registry. Website and app receive the same shape.
*/
private static function surface_hero_defaults(): array
{
$defaults = [
'places' => ['label' => 'Orte', 'title' => ['de_DE' => 'Orte in Weimar', 'en_US' => 'Places in Weimar'], 'body' => ['de_DE' => 'Entdecke besondere Orte, Gastro und Empfehlungen in Weimar und der Region.', 'en_US' => 'Discover places, food and recommendations in Weimar and the region.']],
'events' => ['label' => 'Events', 'title' => ['de_DE' => 'Events in Weimar', 'en_US' => 'Events in Weimar'], 'body' => ['de_DE' => 'Finde Veranstaltungen, Termine und Erlebnisse rund um Weimar.', 'en_US' => 'Find events, dates and experiences around Weimar.']],
'services' => ['label' => 'Services und Einrichtungen', 'title' => ['de_DE' => 'Services in Weimar', 'en_US' => 'Services in Weimar'], 'body' => ['de_DE' => 'Wichtige Services, Einrichtungen und Anlaufstellen in deiner Nähe.', 'en_US' => 'Important services, facilities and contact points near you.']],
'classifieds' => ['label' => 'Kleinanzeigen', 'title' => ['de_DE' => 'Kleinanzeigen in Weimar', 'en_US' => 'Classifieds in Weimar'], 'body' => ['de_DE' => 'Finde Angebote und Gesuche aus Weimar und der Region.', 'en_US' => 'Find offers and requests from Weimar and the region.']],
'jobs' => ['label' => 'Jobs', 'title' => ['de_DE' => 'Jobs in Weimar', 'en_US' => 'Jobs in Weimar'], 'body' => ['de_DE' => 'Lokale Stellenangebote aus Weimar und der Region.', 'en_US' => 'Local jobs from Weimar and the surrounding region.']],
'real-estate' => ['label' => 'Immobilien', 'title' => ['de_DE' => 'Immobilien in Weimar', 'en_US' => 'Real estate in Weimar'], 'body' => ['de_DE' => 'Wohnungen, Häuser und Grundstücke in Weimar und Umgebung.', 'en_US' => 'Apartments, houses and land in Weimar and the surrounding area.']],
'news' => ['label' => 'News', 'title' => ['de_DE' => 'News aus Weimar', 'en_US' => 'News from Weimar'], 'body' => ['de_DE' => 'Aktuelle Nachrichten und redaktionelle Empfehlungen aus Weimar.', 'en_US' => 'Latest news and editorial recommendations from Weimar.']],
'guides' => ['label' => 'Ratgeber', 'title' => ['de_DE' => 'Ratgeber für Weimar', 'en_US' => 'Weimar guides'], 'body' => ['de_DE' => 'Tipps, Hintergründe und hilfreiche Informationen für deinen Alltag.', 'en_US' => 'Tips, background and useful information for everyday life.']],
];
foreach ($defaults as $route => $copy) {
$defaults[$route] = array_merge([
'id' => $route,
'label' => $route,
'eyebrow' => [],
'title' => [],
'body' => [],
'image_id' => 0,
'logo_id' => 0,
'icon_id' => 0,
'height_mode' => 'content',
'image_fit' => 'cover',
'colors' => [
'title' => '#ffffff',
'body' => '#ffffff',
'eyebrow' => '',
'button' => '',
'button_text' => '#ffffff',
'secondary_button' => '',
'secondary_button_text' => '',
],
], $copy);
}
return $defaults;
}
public static function surface_heroes_field(): void
{
$settings = self::get();
$heroes = self::sanitize_surface_heroes($settings['surface_heroes'] ?? []);
$locales = self::normalize_locales($settings['locales'] ?? self::defaults()['locales']);
echo '<div class="mw-surface-heroes">';
echo '<div class="mw-hero-campaigns__intro"><p>' . esc_html__('Jedes öffentliche Modul erhält hier dieselbe steuerbare Header-Struktur: lokalisierte Texte, Mediathek-Bild, Darstellung und Text-/Buttonfarben. Die App ergänzt keine eigenen Modultexte.', 'meinweimar-core') . '</p><p class="description">' . esc_html__('Ein leeres Bild verwendet den Inhalts-Fallback. Eigene Farben werden nur übernommen, wenn sie aktiviert sind; sonst greifen die globalen Theme-Farben.', 'meinweimar-core') . '</p></div>';
foreach ($heroes as $route => $hero) {
self::render_surface_hero_card((string) $route, $hero, $locales);
}
echo '</div>';
}
private static function render_surface_hero_card(string $route, array $hero, array $locales): void
{
$prefix = self::OPTION . '[surface_heroes][' . $route . ']';
$slug = 'surface-hero-' . sanitize_key($route);
echo '<article class="mw-hero-campaign mw-surface-hero" data-mw-surface-hero="' . esc_attr($route) . '">';
echo '<div class="mw-hero-campaign__header"><div><strong>' . esc_html((string) ($hero['label'] ?? $route)) . '</strong><span class="mw-surface-hero__route">' . esc_html($route) . '</span></div></div>';
echo '<input type="hidden" name="' . esc_attr($prefix . '[id]') . '" value="' . esc_attr($route) . '">';
echo '<div class="mw-hero-campaign__meta">';
echo '<label><span>' . esc_html__('Höhenmodus', 'meinweimar-core') . '</span><select name="' . esc_attr($prefix . '[height_mode]') . '"><option value="content"' . selected($hero['height_mode'], 'content', false) . '>' . esc_html__('Inhaltsabhängig', 'meinweimar-core') . '</option><option value="image"' . selected($hero['height_mode'], 'image', false) . '>' . esc_html__('Bildproportion', 'meinweimar-core') . '</option></select></label>';
echo '<label><span>' . esc_html__('Bilddarstellung', 'meinweimar-core') . '</span><select name="' . esc_attr($prefix . '[image_fit]') . '"><option value="cover"' . selected($hero['image_fit'], 'cover', false) . '>' . esc_html__('Ausfüllen / Zuschneiden', 'meinweimar-core') . '</option><option value="contain"' . selected($hero['image_fit'], 'contain', false) . '>' . esc_html__('Vollständig anzeigen', 'meinweimar-core') . '</option></select></label>';
echo '</div>';
echo '<div class="mw-hero-campaign__copy">';
foreach (['eyebrow' => 'Hero-Eyebrow', 'title' => 'Überschrift', 'body' => 'Unterzeile'] as $key => $label) {
$values = self::normalize_localized_text($hero[$key] ?? []);
echo '<label><span>' . esc_html__($label, 'meinweimar-core') . '</span><textarea class="large-text code" rows="2" name="' . esc_attr($prefix . '[' . $key . '_json]') . '">' . esc_textarea((string) wp_json_encode($values, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</textarea><small>' . esc_html__('Locale-Objekt, z. B. {"de_DE":"…","en_US":"…"}.', 'meinweimar-core') . '</small></label>';
}
echo '</div>';
echo '<div class="mw-hero-campaign__colors">';
foreach (['title' => 'Überschriftenfarbe', 'body' => 'Unterzeilenfarbe', 'eyebrow' => 'Eyebrow-Farbe', 'button' => 'Suchbutton-Farbe', 'button_text' => 'Button-Textfarbe'] as $key => $label) {
self::hero_campaign_color_field($prefix, $key, $label, (string) ($hero['colors'][$key] ?? ''));
}
echo '</div>';
echo '<div class="mw-hero-campaign__media">';
self::hero_campaign_media_field($prefix, $slug . '-image', 'image_id', absint($hero['image_id'] ?? 0), __('Modul-Headerbild aus Mediathek wählen', 'meinweimar-core'));
self::hero_campaign_media_field($prefix, $slug . '-logo', 'logo_id', absint($hero['logo_id'] ?? 0), __('Optionales Modul-Logo aus Mediathek wählen', 'meinweimar-core'));
self::hero_campaign_media_field($prefix, $slug . '-icon', 'icon_id', absint($hero['icon_id'] ?? 0), __('Optionales Modul-Icon aus Mediathek wählen', 'meinweimar-core'));
echo '</div>';
echo '</article>';
}
public static function home_sections_field(): void
{
$settings = self::get();
$sections = self::normalize_home_sections($settings['home_sections'] ?? []);
$locales = self::normalize_locales($settings['locales'] ?? self::defaults()['locales']);
echo '<div class="mw-home-sections" data-mw-home-sections>';
echo '<div class="mw-home-sections__intro">';
echo '<p>' . esc_html__('Ziehe die Karten an die gewünschte Position. Die Reihenfolge wird für die vertikale Home-Startseite in Website und App übernommen.', 'meinweimar-core') . '</p>';
echo '<p class="description">' . esc_html__('Überschriften werden je Locale gepflegt. Ein eigenes Icon aus der Mediathek überschreibt das Flutter-Icon; bleibt es leer, greift der Icon-Fallback.', 'meinweimar-core') . '</p>';
echo '<p class="description" data-mw-home-save-status>' . esc_html__('Änderungen werden beim Speichern der Seite übernommen.', 'meinweimar-core') . '</p>';
echo '</div>';
echo '<input type="hidden" data-mw-home-sections-json name="' . esc_attr(self::OPTION) . '[home_sections_json]" value="' . esc_attr((string) wp_json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '">';
echo '<div class="mw-home-sections__list" data-mw-home-section-list>';
foreach ($sections as $section) {
self::render_home_section_card($section, $locales);
}
echo '</div>';
echo '</div>';
}
private static function render_home_section_card(array $section, array $locales): void
{
$id = sanitize_key((string) ($section['id'] ?? ''));
$defaults = [];
foreach (self::home_section_defaults() as $default) {
$defaults[$default['id']] = $default;
}
$default = $defaults[$id] ?? [];
$section = array_merge($default, $section);
$title_values = self::normalize_localized_text($section['title'] ?? []);
$icon_id = absint($section['icon_image_id'] ?? 0);
$input_id = 'mw-home-section-icon-' . $id;
$label = (string) ($section['admin_label'] ?? $id);
echo '<article class="mw-home-section-card" data-mw-home-section-card data-section-id="' . esc_attr($id) . '">';
echo '<header class="mw-home-section-card__header">';
echo '<span class="mw-home-section-card__handle" data-mw-home-section-handle aria-hidden="true">⋮⋮</span>';
echo '<div class="mw-home-section-card__heading"><strong>' . esc_html__($label, 'meinweimar-core') . '</strong><small>' . esc_html($id) . '</small></div>';
echo '<label class="mw-home-section-card__enabled"><input type="checkbox" data-mw-home-enabled value="1"' . checked(!empty($section['enabled']), true, false) . '> ' . esc_html__('Anzeigen', 'meinweimar-core') . '</label>';
echo '<button type="button" class="button-link mw-home-section-card__move" data-mw-home-move="up" aria-label="' . esc_attr__('Eine Position nach oben', 'meinweimar-core') . '">↑</button>';
echo '<button type="button" class="button-link mw-home-section-card__move" data-mw-home-move="down" aria-label="' . esc_attr__('Eine Position nach unten', 'meinweimar-core') . '">↓</button>';
echo '</header>';
echo '<div class="mw-home-section-card__body">';
echo '<div class="mw-home-section-card__titles">';
echo '<strong>' . esc_html__('Abschnittsüberschrift', 'meinweimar-core') . '</strong>';
foreach ($locales as $locale) {
echo '<label><span>' . esc_html($locale) . '</span><input type="text" data-mw-home-title-locale="' . esc_attr($locale) . '" value="' . esc_attr((string) ($title_values[$locale] ?? '')) . '"></label>';
}
echo '</div>';
echo '<div class="mw-home-section-card__icon">';
echo '<label><span>' . esc_html__('Flutter-Icon-Fallback', 'meinweimar-core') . '</span><input type="text" data-mw-home-icon value="' . esc_attr((string) ($section['icon'] ?? 'grid_view')) . '" placeholder="' . esc_attr__('z. B. event, bolt, people', 'meinweimar-core') . '"></label>';
echo '<label><span>' . esc_html__('Eigenes Icon aus der Mediathek', 'meinweimar-core') . '</span><input type="hidden" id="' . esc_attr($input_id) . '" data-mw-home-icon-image value="' . esc_attr((string) $icon_id) . '"><span class="mw-home-section-card__media-actions"><button type="button" class="button" data-mw-logo-picker data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($input_id . '-preview') . '" data-title="' . esc_attr__('Home-Icon auswählen', 'meinweimar-core') . '" data-button-text="' . esc_attr__('Icon verwenden', 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Icon ausgewählt', 'meinweimar-core') . '">' . esc_html__('Icon aus Mediathek wählen', 'meinweimar-core') . '</button><button type="button" class="button-link-delete" data-mw-home-icon-clear data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($input_id . '-preview') . '" data-clear-label="' . esc_attr__('Flutter-Icon-Fallback aktiv', 'meinweimar-core') . '">' . esc_html__('Eigenes Icon entfernen', 'meinweimar-core') . '</button></span><span id="' . esc_attr($input_id . '-preview') . '">' . ($icon_id ? esc_html__('Ausgewählt', 'meinweimar-core') : esc_html__('Flutter-Icon-Fallback aktiv', 'meinweimar-core')) . '</span></label>';
echo '</div>';
if ('quick_access' === $id) {
echo '<div class="mw-home-section-card__quick-access" data-mw-home-quick-access>';
echo '<div class="mw-home-section-card__quick-heading"><strong>' . esc_html__('Runde Schnellzugriff-Kategorien', 'meinweimar-core') . '</strong><small>' . esc_html__('Auswahl kommt ausschließlich aus den registrierten WordPress-Taxonomien. Ein Bild ist optional; ohne Bild verwendet die App das Modul-Icon.', 'meinweimar-core') . '</small></div>';
echo '<div class="mw-home-quick-items" data-mw-home-quick-items>';
foreach ((array) ($section['items'] ?? []) as $index => $quick_item) {
self::render_home_quick_item((array) $quick_item, $locales, (int) $index);
}
echo '</div>';
ob_start();
self::render_home_quick_item(
['route' => 'places', 'category' => '', 'label' => [], 'image_id' => 0],
$locales,
0
);
$quick_template = ob_get_clean();
echo '<template data-mw-home-quick-template>' . $quick_template . '</template>';
echo '<button type="button" class="button" data-mw-home-quick-add>' . esc_html__('Weitere Kategorie hinzufügen', 'meinweimar-core') . '</button>';
echo '</div>';
}
echo '</div>';
echo '</article>';
}
private static function render_home_quick_item(array $item, array $locales, int $index): void
{
$route = sanitize_key((string) ($item['route'] ?? 'places'));
$category = sanitize_title((string) ($item['category'] ?? ''));
$selected = $route . '|' . $category;
$labels = self::normalize_localized_text($item['label'] ?? []);
$input_id = 'mw-home-quick-image-' . $index . '-' . wp_rand(1000, 9999);
$image_id = absint($item['image_id'] ?? 0);
echo '<div class="mw-home-quick-item" data-mw-home-quick-item>';
echo '<div class="mw-home-quick-item__controls">';
echo '<label><span>' . esc_html__('Modul und Kategorie', 'meinweimar-core') . '</span><select data-mw-home-quick-route>' . self::home_quick_access_term_options($selected) . '</select></label>';
foreach ($locales as $locale) {
echo '<label><span>' . esc_html($locale) . '</span><input type="text" data-mw-home-quick-label-locale="' . esc_attr($locale) . '" value="' . esc_attr((string) ($labels[$locale] ?? '')) . '" placeholder="' . esc_attr__('Optional: eigene Bezeichnung', 'meinweimar-core') . '"></label>';
}
echo '</div>';
echo '<div class="mw-home-quick-item__media">';
echo '<input type="hidden" id="' . esc_attr($input_id) . '" data-mw-home-quick-image value="' . esc_attr((string) $image_id) . '">';
echo '<button type="button" class="button" data-mw-logo-picker data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($input_id . '-preview') . '" data-title="' . esc_attr__('Kategorie-Bild auswählen', 'meinweimar-core') . '" data-button-text="' . esc_attr__('Bild verwenden', 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Bild ausgewählt', 'meinweimar-core') . '">' . esc_html__('Bild auswählen', 'meinweimar-core') . '</button>';
echo '<span id="' . esc_attr($input_id . '-preview') . '">' . ($image_id ? esc_html__('Bild ausgewählt', 'meinweimar-core') : esc_html__('Modul-Icon als Fallback', 'meinweimar-core')) . '</span>';
echo '<button type="button" class="button-link-delete" data-mw-home-quick-image-clear data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($input_id . '-preview') . '">' . esc_html__('Eigenes Bild entfernen', 'meinweimar-core') . '</button>';
echo '<button type="button" class="button-link-delete" data-mw-home-quick-remove>' . esc_html__('Eintrag entfernen', 'meinweimar-core') . '</button>';
echo '</div>';
echo '</div>';
}
private static function home_quick_access_term_options(string $selected): string
{
$html = '';
foreach (ContentTypes::definitions() as $route => $definition) {
$route = sanitize_key((string) $route);
$taxonomy = sanitize_key((string) ($definition['category'] ?? ''));
if (!$route || !$taxonomy) continue;
$html .= '<option value="' . esc_attr($route . '|') . '"' . selected($selected, $route . '|', false) . '>' . esc_html((string) ($definition['label'] ?? $route)) . ' — ' . esc_html__('alle Kategorien', 'meinweimar-core') . '</option>';
if (!function_exists('get_terms') || !function_exists('taxonomy_exists') || !taxonomy_exists($taxonomy)) continue;
$terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'parent' => 0, 'orderby' => 'name', 'order' => 'ASC']);
if (is_wp_error($terms)) continue;
foreach ($terms as $term) {
$html .= self::home_quick_access_term_option_tree($route, $taxonomy, $term, $selected, ' ');
}
}
return $html;
}
private static function home_quick_access_term_option_tree(string $route, string $taxonomy, \WP_Term $term, string $selected, string $prefix): string
{
$value = $route . '|' . $term->slug;
$html = '<option value="' . esc_attr($value) . '"' . selected($selected, $value, false) . '>' . esc_html($prefix . $term->name) . '</option>';
$children = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'parent' => (int) $term->term_id, 'orderby' => 'name', 'order' => 'ASC']);
if (is_wp_error($children)) return $html;
foreach ($children as $child) {
$html .= self::home_quick_access_term_option_tree($route, $taxonomy, $child, $selected, $prefix . ' ');
}
return $html;
}
public static function navigation_surface_field(array $args): void
{
$key = sanitize_key((string) ($args['key'] ?? 'navigation_bottom'));
$settings = self::get();
$fallback = $settings['navigation'] ?? self::defaults()['navigation'];
$value = !empty($settings[$key]) ? $settings[$key] : $fallback;
echo '<textarea class="large-text code" rows="14" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . '_json]">' . esc_textarea((string) wp_json_encode(self::normalize_navigation($value), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</textarea>';
echo '<p class="description">' . esc_html__('Array-Reihenfolge = sichtbare Reihenfolge. Felder: id, label_key, label, route, icon, enabled, primary. Eigene Medien werden über die Icon-Felder darunter zugeordnet.', 'meinweimar-core') . '</p>';
}
public static function navigation_icon_field(array $args): void
{
$id = sanitize_key((string) ($args['id'] ?? ''));
$settings = self::get();
$value = absint(((array) ($settings['navigation_icons'] ?? []))[$id] ?? 0);
self::image_map_field('navigation_icons', $id, $value, (string) ($args['label'] ?? $id), 'App-Icon aus Mediathek wählen');
}
public static function resource_icon_field(array $args): void
{
$route = sanitize_key((string) ($args['route'] ?? ''));
$settings = self::get();
$value = absint(((array) ($settings['resource_icons'] ?? []))[$route] ?? 0);
self::image_map_field('resource_icons', $route, $value, (string) ($args['label'] ?? $route), 'CPT-Icon aus Mediathek wählen');
}
public static function field_icon_field(array $args): void
{
$field = sanitize_key((string) ($args['field'] ?? ''));
$settings = self::get();
$value = absint(((array) ($settings['field_icons'] ?? []))[$field] ?? 0);
self::image_map_field('field_icons', $field, $value, (string) ($args['label'] ?? $field), 'Feld-Icon aus Mediathek wählen');
}
private static function field_icon_definitions(): array
{
return [
'company_name' => __('Organisation / Unternehmen', 'meinweimar-core'),
'department' => __('Abteilung / Team', 'meinweimar-core'),
'work_hours' => __('Arbeitszeit', 'meinweimar-core'),
'start_date' => __('Arbeitsbeginn', 'meinweimar-core'),
'application_deadline' => __('Bewerbungsfrist', 'meinweimar-core'),
'languages' => __('Sprachen', 'meinweimar-core'),
'education' => __('Ausbildung / Abschluss', 'meinweimar-core'),
'experience_years' => __('Erfahrung', 'meinweimar-core'),
];
}
private static function image_map_field(string $map, string $key, int $value, string $label, string $button_label): void
{
$input_id = 'mw-' . sanitize_key($map) . '-' . sanitize_key($key) . '-id';
$preview_id = $input_id . '-preview';
$name = self::OPTION . '[' . $map . '][' . $key . ']';
echo '<input type="hidden" id="' . esc_attr($input_id) . '" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '">';
echo '<button type="button" class="button" data-mw-logo-picker data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($preview_id) . '" data-title="' . esc_attr($label) . '" data-button-text="' . esc_attr__($button_label, 'meinweimar-core') . '" data-preview-label="' . esc_attr__('Ausgewählt', 'meinweimar-core') . '">' . esc_html__($button_label, 'meinweimar-core') . '</button> ';
echo '<button type="button" class="button-link-delete" data-mw-image-map-clear data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($preview_id) . '" data-clear-label="' . esc_attr__('Flutter-Icon-Fallback aktiv', 'meinweimar-core') . '">' . esc_html__('Eigenes Icon entfernen', 'meinweimar-core') . '</button> ';
echo '<span id="' . esc_attr($preview_id) . '">' . ($value ? esc_html__('Ausgewählt', 'meinweimar-core') : esc_html__('Flutter-Icon-Fallback aktiv', 'meinweimar-core')) . '</span>';
}
public static function app_texts_field(): void
{
$value = self::normalize_app_texts(self::get()['app_texts'] ?? []);
echo '<textarea class="large-text code" rows="18" name="' . esc_attr(self::OPTION) . '[app_texts_json]">' . esc_textarea((string) wp_json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . '</textarea>';
echo '<p class="description">' . esc_html__('Schlüssel entsprechen den Flutter-Locale-Schlüsseln. Nicht gesetzte Schlüssel fallen auf die gebündelte Sprachdatei zurück. Parameter können als {count}, {title}, {status} usw. angegeben werden.', 'meinweimar-core') . '</p>';
}
public static function text_field(array $args): void
{
$key = $args['key'];
$value = self::get()[$key] ?? '';
echo '<input class="regular-text" type="text" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '">';
}
public static function select_field(array $args): void
{
$key = sanitize_key((string) ($args['key'] ?? ''));
$options = is_array($args['options'] ?? null) ? $args['options'] : [];
$value = (string) (self::get()[$key] ?? array_key_first($options));
echo '<select name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']">';
foreach ($options as $option_value => $label) {
echo '<option value="' . esc_attr((string) $option_value) . '"' . selected($value, (string) $option_value, false) . '>' . esc_html((string) $label) . '</option>';
}
echo '</select>';
}
public static function checkbox_field(array $args): void
{
$key = (string) ($args['key'] ?? '');
$value = filter_var(self::get()[$key] ?? false, FILTER_VALIDATE_BOOLEAN);
echo '<label><input type="checkbox" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="1"' . checked($value, true, false) . '> ' . esc_html((string) ($args['label'] ?? '')) . '</label>';
}
public static function locale_select_field(array $args): void
{
$key = (string) $args['key'];
$settings = self::get();
$locales = self::normalize_locales($settings['locales'] ?? []);
$value = self::resolve_locale($settings[$key] ?? '', $locales, $locales[0]);
echo '<select name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']">';
foreach ($locales as $locale) {
echo '<option value="' . esc_attr($locale) . '"' . selected($value, $locale, false) . '>' . esc_html($locale) . '</option>';
}
echo '</select>';
}
public static function color_field(array $args): void
{
$key = (string) $args['key'];
$defaults = self::defaults();
$settings = self::get();
$hero_fallbacks = [
'hero_title_color' => '#ffffff',
'hero_body_color' => '#ffffff',
'hero_eyebrow_color' => self::color_value('accent_color', $settings['accent_color'] ?? '', $defaults['accent_color']),
'hero_button_color' => self::color_value('primary_color', $settings['primary_color'] ?? '', $defaults['primary_color']),
'hero_button_text_color' => '#ffffff',
'hero_secondary_button_color' => self::color_value('secondary_color', $settings['secondary_color'] ?? '', $defaults['secondary_color']),
'hero_secondary_button_text_color' => '#ffffff',
];
$fallback = $hero_fallbacks[$key] ?? (string) ($defaults[$key] ?? '#20242e');
$value = self::color_value($key, $settings[$key] ?? '', $fallback);
echo '<input type="color" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '">';
}
public static function url_field(array $args): void
{
$key = (string) $args['key'];
$settings = self::get();
$value = $settings[$key] ?? '';
echo '<input class="regular-text code" type="url" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '" placeholder="https://…">';
if ('geo_routing_url' === $key) {
echo '<p class="description">' . esc_html__('Optional: wird nicht für flutter_map benötigt, sondern nur für einen zentralen Route-/In-App-Navigationsdienst.', 'meinweimar-core') . '</p>';
} elseif ('geo_external_navigation_url' === $key) {
echo '<p class="description">' . esc_html__('Optionales HTTPS-Template für einen eigenen Web-Fallback. Bei Google Maps bleibt das Feld leer: App/Google-Maps-Link und lokale System-Navigation werden automatisch versucht. Unterstützte Platzhalter: {lat}, {lng}, {coordinates}, {destination}, {address}.', 'meinweimar-core') . '</p>';
} elseif ('geo_geocoding_url' === $key) {
echo '<p class="description">' . esc_html__('Nur erforderlich, wenn freie Adresssuche über die gemeinsame API angeboten werden soll.', 'meinweimar-core') . '</p>';
}
}
public static function google_maps_api_key_field(): void
{
$configured = self::google_maps_frontend_config()['enabled'];
echo '<input class="regular-text code" type="password" autocomplete="new-password" name="' . esc_attr(self::OPTION) . '[geo_google_maps_api_key]" value="" placeholder="AIza…">';
echo '<p class="description">' . esc_html($configured ? __('Ein Key ist aktiv. Leer lassen, um ihn beizubehalten.', 'meinweimar-core') : __('Key aus der Google Cloud Console einfügen. Leer lassen, um eine optionale wp-config-Konstante zu verwenden.', 'meinweimar-core')) . '</p>';
}
public static function google_maps_status_field(): void
{
$geo = (array) (self::public_config()['geo'] ?? []);
$navigation = 'google_maps' === (string) ($geo['navigation_provider'] ?? '')
? __('aktiv', 'meinweimar-core')
: __('nicht aktiv', 'meinweimar-core');
$website = self::google_maps_frontend_config()['enabled']
? __('konfiguriert', 'meinweimar-core')
: __('noch nicht konfiguriert', 'meinweimar-core');
$tiles = !empty($geo['map_configured'])
? __('konfiguriert', 'meinweimar-core')
: __('noch nicht konfiguriert', 'meinweimar-core');
echo '<div class="mw-google-maps-status">';
echo '<p><strong>' . esc_html__('Externe Navigation:', 'meinweimar-core') . '</strong> ' . esc_html(sprintf(__('Google Maps (%s)', 'meinweimar-core'), $navigation)) . '</p>';
echo '<p><strong>' . esc_html__('Website-Karte:', 'meinweimar-core') . '</strong> ' . esc_html(sprintf(__('Google Maps JavaScript (%s)', 'meinweimar-core'), $website)) . '</p>';
echo '<p><strong>' . esc_html__('Google-Tiles-/Session-Quelle:', 'meinweimar-core') . '</strong> ' . esc_html($tiles) . '</p>';
echo '<p class="description">' . esc_html__('Es gibt hier absichtlich keine OpenStreetMap-, Nominatim-, Routing- oder freien Kachel-URL-Felder mehr. Der separate Tiles-/Session-Gate bleibt für die alte providerneutrale API geschlossen; die Website-Karte verwendet den eingeschränkten Google-Maps-Platform-Key.', 'meinweimar-core') . '</p>';
echo '</div>';
}
public static function marker_field(array $args): void
{
$route = isset($args['route']) ? (string) $args['route'] : '';
$user = !empty($args['user']);
$key = $route
? 'geo_resource_markers][' . $route
: ($user ? 'geo_user_marker_id' : 'geo_fallback_marker_id');
$settings = self::get();
$value = $route
? absint(((array) ($settings['geo_resource_markers'] ?? []))[$route] ?? 0)
: absint($settings[$user ? 'geo_user_marker_id' : 'geo_fallback_marker_id'] ?? 0);
$input_id = 'mw-marker-' . ($route ?: ($user ? 'user' : 'fallback'));
$preview_id = $input_id . '-preview';
$name = self::OPTION . '[' . $key . ']';
$label = $value ? sprintf(__('Bild #%d ausgewählt', 'meinweimar-core'), $value) : __('Noch kein Marker ausgewählt', 'meinweimar-core');
echo '<input type="hidden" id="' . esc_attr($input_id) . '" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '">';
echo '<button type="button" class="button" data-mw-marker-picker data-input="' . esc_attr($input_id) . '" data-preview="' . esc_attr($preview_id) . '">' . esc_html__('Marker aus Mediathek wählen', 'meinweimar-core') . '</button> ';
echo '<span id="' . esc_attr($preview_id) . '">' . esc_html($label) . '</span>';
if ($user) {
echo '<p class="description">' . esc_html__('Dieser Marker wird für den privaten Live-GPS-Standort verwendet. Wenn kein Bild gewählt ist, verwendet die App ihren gebündelten Standortmarker als Fallback.', 'meinweimar-core') . '</p>';
}
}
public static function number_field(array $args): void
{
$key = $args['key'];
$value = self::get()[$key] ?? '';
echo '<input type="number" name="' . esc_attr(self::OPTION) . '[' . esc_attr($key) . ']" value="' . esc_attr((string) $value) . '" min="' . esc_attr((string) $args['min']) . '" max="' . esc_attr((string) $args['max']) . '" step="' . esc_attr((string) ($args['step'] ?? '1')) . '">';
}
public static function tile_styles_field(): void
{
$styles = wp_json_encode(self::sanitize_tile_styles(self::get()['geo_tile_styles'] ?? []), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
echo '<textarea class="large-text code" rows="7" name="' . esc_attr(self::OPTION) . '[geo_tile_styles_json]">' . esc_textarea((string) $styles) . '</textarea>';
echo '<p class="description">' . esc_html__('Optionale road/light/dark/satellite-Templates. Nur vollständig konfigurierte Provider werden im Kartenadapter angeboten; Satellit wird nicht aus OSM abgeleitet.', 'meinweimar-core') . '</p>';
}
public static function navigation_field(): void
{
$json = wp_json_encode(self::normalize_navigation(self::get()['navigation']), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
echo '<textarea class="large-text code" rows="14" name="' . esc_attr(self::OPTION) . '[navigation_json]">' . esc_textarea((string) $json) . '</textarea>';
echo '<p class="description">' . esc_html__('JSON-Konfiguration für Website und App. Jede Position kann id, label, label_key, icon, route, enabled und primary enthalten. Änderungen werden über die zentrale Config-API ausgegeben.', 'meinweimar-core') . '</p>';
}
public static function locales_field(): void
{
$locales = wp_json_encode(self::normalize_locales(self::get()['locales']), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
echo '<textarea class="large-text code" rows="4" name="' . esc_attr(self::OPTION) . '[locales_json]">' . esc_textarea((string) $locales) . '</textarea>';
echo '<p class="description">' . esc_html__('JSON-Liste für App-Sprachdateien und lokalisierte WordPress-Inhalte, zum Beispiel ["de_DE", "en_US"]. Keine sichtbaren UI-Texte in Flutter hardcodieren.', 'meinweimar-core') . '</p>';
}
private static function color_value(string $key, $value, string $fallback): string
{
$color = sanitize_hex_color((string) $value);
// #0070e4 was the old accidental app fallback. Treat it as legacy
// only for this setting so an existing option cannot reintroduce the
// unapproved blue without an explicit new configuration.
if ('secondary_color' === $key && '#0070e4' === strtolower((string) $color)) {
return $fallback;
}
return $color ?: $fallback;
}
private static function google_maps_api_key_value($value): string
{
$candidate = trim(sanitize_text_field((string) wp_unslash($value)));
if ('' === $candidate) {
$candidate = trim((string) (self::get()['geo_google_maps_api_key'] ?? ''));
}
return preg_replace('/[^A-Za-z0-9_-]/', '', $candidate) ?: '';
}
private static function public_colors(array $settings): array
{
$fallbacks = self::defaults();
$keys = ['primary_color', 'secondary_color', 'accent_color', 'ink_color', 'surface_color', 'muted_color', 'brand_surface_color', 'success_color', 'warning_color'];
$colors = [];
foreach ($keys as $key) {
$colors[$key] = self::color_value($key, $settings[$key] ?? '', $fallbacks[$key]);
}
return $colors;
}
private static function normalize_localized_text($value): array
{
if (is_string($value)) {
$decoded = json_decode(wp_unslash($value), true);
$value = is_array($decoded) ? $decoded : [];
}
$normalized = [];
foreach ((array) $value as $locale => $text) {
if (!is_scalar($text)) {
continue;
}
$locale = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $locale);
if (!$locale || strlen($locale) > 20) {
continue;
}
$normalized[$locale] = sanitize_textarea_field((string) $text);
}
return $normalized;
}
/**
* Return the configured Hero copy. During the one-time admin seed window,
* expose the plugin defaults read-only so the public contract is already
* usable before the settings screen has been opened once.
*/
private static function hero_copy(array $settings): array
{
$defaults = self::defaults();
$seeded = self::HERO_COPY_DEFAULTS_VERSION === (string) get_option(self::HERO_COPY_DEFAULTS_OPTION, '');
$copy = [];
foreach (self::HERO_COPY_KEYS as $key) {
$value = str_ends_with($key, '_url')
? self::normalize_localized_links($settings[$key] ?? [])
: self::normalize_localized_text($settings[$key] ?? []);
if (!$seeded && empty($value) && !empty($defaults[$key])) {
$value = str_ends_with($key, '_url')
? self::normalize_localized_links($defaults[$key])
: self::normalize_localized_text($defaults[$key]);
}
$copy[$key] = $value;
}
return $copy;
}
private static function hero_campaign_defaults(array $campaign, string $index = '0'): array
{
return array_merge([
'id' => 'hero-' . sanitize_key($index),
'label' => '',
'active' => false,
'priority' => 0,
'start_at' => '',
'end_at' => '',
'eyebrow' => [],
'title' => [],
'body' => [],
'primary_label' => [],
'primary_url' => [],
'secondary_label' => [],
'secondary_url' => [],
'image_id' => 0,
'logo_id' => 0,
'icon_id' => 0,
'height_mode' => 'inherit',
'image_fit' => 'inherit',
'colors' => [
'title' => '',
'body' => '',
'eyebrow' => '',
'button' => '',
'button_text' => '',
'secondary_button' => '',
'secondary_button_text' => '',
],
], $campaign);
}
private static function sanitize_hero_campaigns($value): array
{
if (is_string($value)) {
$decoded = json_decode(wp_unslash($value), true);
$value = is_array($decoded) ? $decoded : [];
}
$normalized = [];
$used_ids = [];
foreach ((array) $value as $index => $campaign) {
if (!is_array($campaign)) {
continue;
}
$campaign = self::hero_campaign_defaults($campaign, (string) $index);
$id = sanitize_key((string) $campaign['id']);
if (!$id) {
$id = 'hero-' . sanitize_key((string) $index);
}
$base_id = $id;
$suffix = 2;
while (isset($used_ids[$id])) {
$id = $base_id . '-' . $suffix;
$suffix++;
}
$used_ids[$id] = true;
$normalized[] = [
'id' => $id,
'label' => sanitize_text_field((string) $campaign['label']),
'active' => filter_var($campaign['active'], FILTER_VALIDATE_BOOLEAN),
'priority' => max(0, min(100, absint($campaign['priority']))),
'start_at' => self::date_value($campaign['start_at']),
'end_at' => self::date_value($campaign['end_at']),
'eyebrow' => self::normalize_localized_text($campaign['eyebrow_json'] ?? $campaign['eyebrow']),
'title' => self::normalize_localized_text($campaign['title_json'] ?? $campaign['title']),
'body' => self::normalize_localized_text($campaign['body_json'] ?? $campaign['body']),
'primary_label' => self::normalize_localized_text($campaign['primary_label_json'] ?? $campaign['primary_label']),
'primary_url' => self::normalize_localized_links($campaign['primary_url_json'] ?? $campaign['primary_url']),
'secondary_label' => self::normalize_localized_text($campaign['secondary_label_json'] ?? $campaign['secondary_label']),
'secondary_url' => self::normalize_localized_links($campaign['secondary_url_json'] ?? $campaign['secondary_url']),
'image_id' => self::image_id($campaign['image_id']),
'logo_id' => self::image_id($campaign['logo_id']),
'icon_id' => self::image_id($campaign['icon_id']),
'height_mode' => self::hero_campaign_height_mode($campaign['height_mode']),
'image_fit' => self::hero_campaign_image_fit($campaign['image_fit']),
'colors' => self::sanitize_hero_campaign_colors($campaign['colors'] ?? []),
];
}
return $normalized;
}
private static function date_value($value): string
{
$value = sanitize_text_field((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
}
private static function hero_height_mode($value): string
{
return 'image' === sanitize_key((string) $value) ? 'image' : 'content';
}
private static function hero_image_fit($value): string
{
return 'contain' === sanitize_key((string) $value) ? 'contain' : 'cover';
}
private static function hero_campaign_height_mode($value): string
{
$value = sanitize_key((string) $value);
return in_array($value, ['inherit', 'content', 'image'], true) ? $value : 'inherit';
}
private static function hero_campaign_image_fit($value): string
{
$value = sanitize_key((string) $value);
return in_array($value, ['inherit', 'cover', 'contain'], true) ? $value : 'inherit';
}
private static function hero_colors(array $settings): array
{
$defaults = self::defaults();
return [
'title' => self::color_value('hero_title_color', $settings['hero_title_color'] ?? '', $defaults['hero_title_color']),
'body' => self::color_value('hero_body_color', $settings['hero_body_color'] ?? '', $defaults['hero_body_color']),
'eyebrow' => self::color_value('hero_eyebrow_color', $settings['hero_eyebrow_color'] ?? '', self::color_value('accent_color', $settings['accent_color'] ?? '', $defaults['accent_color'])),
'button' => self::color_value('hero_button_color', $settings['hero_button_color'] ?? '', $defaults['hero_button_color'] ?: ($settings['primary_color'] ?? $defaults['primary_color'])),
'button_text' => self::color_value('hero_button_text_color', $settings['hero_button_text_color'] ?? '', $defaults['hero_button_text_color']),
'secondary_button' => self::color_value('hero_secondary_button_color', $settings['hero_secondary_button_color'] ?? '', $defaults['hero_secondary_button_color'] ?: ($settings['secondary_color'] ?? $defaults['secondary_color'])),
'secondary_button_text' => self::color_value('hero_secondary_button_text_color', $settings['hero_secondary_button_text_color'] ?? '', $defaults['hero_secondary_button_text_color'] ?: $defaults['hero_button_text_color']),
];
}
private static function sanitize_hero_campaign_colors($value): array
{
$value = is_array($value) ? $value : [];
$colors = [];
foreach (['title', 'body', 'eyebrow', 'button', 'button_text', 'secondary_button', 'secondary_button_text'] as $key) {
$enabled = !empty($value[$key . '_enabled']);
$colors[$key] = $enabled ? (sanitize_hex_color((string) ($value[$key] ?? '')) ?: '') : '';
}
return $colors;
}
private static function merge_hero_colors(array $base, array $override): array
{
foreach ($override as $key => $value) {
if (is_string($value) && '' !== $value) {
$base[$key] = $value;
}
}
return $base;
}
private static function active_hero_campaign(array $settings): ?array
{
$today = current_time('Y-m-d');
$eligible = [];
foreach (self::sanitize_hero_campaigns($settings['hero_campaigns'] ?? []) as $index => $campaign) {
if (!$campaign['active']) {
continue;
}
if ($campaign['start_at'] && $today < $campaign['start_at']) {
continue;
}
if ($campaign['end_at'] && $today > $campaign['end_at']) {
continue;
}
$campaign['_index'] = $index;
$eligible[] = $campaign;
}
usort($eligible, static function (array $left, array $right): int {
if ($left['priority'] !== $right['priority']) {
return $right['priority'] <=> $left['priority'];
}
if ($left['start_at'] !== $right['start_at']) {
return strcmp($right['start_at'], $left['start_at']);
}
return $left['_index'] <=> $right['_index'];
});
if (!$eligible) {
return null;
}
unset($eligible[0]['_index']);
return $eligible[0];
}
private static function merge_localized_values(array $base, array $override): array
{
foreach ($override as $locale => $value) {
if (trim((string) $value) !== '') {
$base[$locale] = $value;
}
}
return $base;
}
private static function normalize_localized_links($value): array
{
$texts = self::normalize_localized_text($value);
$links = [];
foreach ($texts as $locale => $link) {
$links[$locale] = self::link_value($link);
}
return $links;
}
private static function normalize_app_texts($value): array
{
if (is_string($value)) {
$decoded = json_decode(wp_unslash($value), true);
$value = is_array($decoded) ? $decoded : [];
}
$normalized = [];
foreach ((array) $value as $locale => $messages) {
if (!is_array($messages)) {
continue;
}
$locale = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $locale);
if (!$locale || strlen($locale) > 20) {
continue;
}
foreach ($messages as $key => $text) {
if (!is_scalar($text)) {
continue;
}
$key = preg_replace('/[^A-Za-z0-9_.-]/', '', (string) $key);
if ($key) {
$normalized[$locale][$key] = sanitize_textarea_field((string) $text);
}
}
}
return $normalized;
}
private static function link_value(string $value): string
{
$value = trim($value);
if (preg_match('~^(https?://|/|#|mailto:|tel:)~i', $value)) {
return str_starts_with($value, '/') || str_starts_with($value, '#') || str_starts_with(strtolower($value), 'mailto:') || str_starts_with(strtolower($value), 'tel:')
? sanitize_text_field($value)
: esc_url_raw($value, ['http', 'https']);
}
return '';
}
private static function sanitize_image_map($value): array
{
$value = is_array($value) ? $value : [];
$normalized = [];
foreach ($value as $key => $attachment_id) {
$key = sanitize_key((string) $key);
if ($key) {
$normalized[$key] = self::image_id($attachment_id);
}
}
return $normalized;
}
private static function public_navigation(array $items, array $icon_map): array
{
return array_map(static function (array $item) use ($icon_map): array {
$icon_id = absint($icon_map[$item['id']] ?? 0);
$icon_id = $icon_id ?: absint($item['icon_image_id'] ?? 0);
if ($icon_id) {
$item['icon_image'] = self::logo_config($icon_id);
}
unset($item['icon_image_id']);
return $item;
}, $items);
}
private static function public_image_map(array $image_map): array
{
$public = [];
foreach ($image_map as $key => $attachment_id) {
$public[(string) $key] = self::logo_config(absint($attachment_id));
}
return $public;
}
private static function public_home_sections($sections): array
{
$public = [];
foreach (self::normalize_home_sections($sections) as $section) {
$item = [
'id' => $section['id'],
'enabled' => !empty($section['enabled']),
'title' => self::normalize_localized_text($section['title'] ?? []),
'title_key' => preg_replace('/[^A-Za-z0-9_.-]/', '', (string) ($section['title_key'] ?? '')),
'icon' => sanitize_key((string) ($section['icon'] ?? 'grid_view')) ?: 'grid_view',
];
$icon_id = absint($section['icon_image_id'] ?? 0);
if ($icon_id) {
$item['icon_image'] = self::logo_config($icon_id);
}
if ('quick_access' === $section['id']) {
$item['items'] = array_map(
static function (array $quick_item): array {
$image = self::logo_config(absint($quick_item['image_id'] ?? 0));
return [
'route' => sanitize_key((string) ($quick_item['route'] ?? '')),
'category' => sanitize_title((string) ($quick_item['category'] ?? '')),
'label' => self::normalize_localized_text($quick_item['label'] ?? []),
'image' => $image,
];
},
(array) ($section['items'] ?? [])
);
}
$public[] = $item;
}
return $public;
}
private static function public_surface_heroes($heroes): array
{
$public = [];
foreach (self::sanitize_surface_heroes($heroes) as $route => $hero) {
$image = self::logo_config(absint($hero['image_id'] ?? 0));
$logo = self::logo_config(absint($hero['logo_id'] ?? 0));
$icon = self::logo_config(absint($hero['icon_id'] ?? 0));
$public[(string) $route] = [
'id' => (string) $route,
'eyebrow' => self::normalize_localized_text($hero['eyebrow'] ?? []),
'title' => self::normalize_localized_text($hero['title'] ?? []),
'body' => self::normalize_localized_text($hero['body'] ?? []),
'image' => $image,
'image_url' => $image['url'],
'image_app_url' => $image['app_url'],
'logo' => $logo,
'icon' => $icon,
'height_mode' => self::hero_height_mode($hero['height_mode'] ?? 'content'),
'image_fit' => self::hero_image_fit($hero['image_fit'] ?? 'cover'),
'colors' => self::surface_hero_colors($hero['colors'] ?? []),
];
}
return $public;
}
private static function sanitize_surface_heroes($value): array
{
$defaults = self::surface_hero_defaults();
$value = is_array($value) ? $value : [];
$normalized = [];
foreach ($defaults as $route => $default) {
$hero = is_array($value[$route] ?? null) ? $value[$route] : [];
$base = array_merge($default, $hero);
$default_colors = (array) ($default['colors'] ?? []);
$colors = is_array($hero['colors'] ?? null) ? $hero['colors'] : [];
$normalized[$route] = [
'id' => $route,
'label' => sanitize_text_field((string) ($base['label'] ?? $route)),
'eyebrow' => self::normalize_localized_text($hero['eyebrow_json'] ?? $base['eyebrow'] ?? []),
'title' => self::normalize_localized_text($hero['title_json'] ?? $base['title'] ?? []),
'body' => self::normalize_localized_text($hero['body_json'] ?? $base['body'] ?? []),
'image_id' => self::image_id($base['image_id'] ?? 0),
'logo_id' => self::image_id($base['logo_id'] ?? 0),
'icon_id' => self::image_id($base['icon_id'] ?? 0),
'height_mode' => self::hero_height_mode($base['height_mode'] ?? 'content'),
'image_fit' => self::hero_image_fit($base['image_fit'] ?? 'cover'),
'colors' => self::sanitize_surface_hero_colors(array_merge($default_colors, $colors), $default_colors),
];
}
return $normalized;
}
private static function sanitize_surface_hero_colors(array $value, array $defaults = []): array
{
$colors = [];
foreach (['title', 'body', 'eyebrow', 'button', 'button_text', 'secondary_button', 'secondary_button_text'] as $key) {
$enabled_key = $key . '_enabled';
if (array_key_exists($enabled_key, $value) && empty($value[$enabled_key])) {
$colors[$key] = '';
continue;
}
$candidate = sanitize_hex_color((string) ($value[$key] ?? ''));
$colors[$key] = $candidate ?: (string) ($defaults[$key] ?? '');
}
return $colors;
}
private static function surface_hero_colors(array $colors): array
{
$public = [];
foreach (['title', 'body', 'eyebrow', 'button', 'button_text', 'secondary_button', 'secondary_button_text'] as $key) {
$public[$key] = sanitize_hex_color((string) ($colors[$key] ?? '')) ?: '';
}
return $public;
}
private static function home_section_defaults(): array
{
return [
[
'id' => 'hero',
'admin_label' => 'Hero',
'enabled' => true,
'title' => [],
'title_key' => '',
'icon' => 'grid_view',
'icon_image_id' => 0,
],
[
'id' => 'quick_access',
'admin_label' => 'Schnellzugriff',
'enabled' => true,
'title' => ['de_DE' => 'Schnellzugriff', 'en_US' => 'Quick access'],
'title_key' => 'homeQuickAccessTitle',
'icon' => 'bolt',
'icon_image_id' => 0,
'items' => self::home_quick_access_defaults(),
],
[
'id' => 'events',
'admin_label' => 'Aktuelle Events',
'enabled' => true,
'title' => ['de_DE' => 'Aktuelle Events', 'en_US' => 'Upcoming events'],
'title_key' => 'homeEventsTitle',
'icon' => 'event',
'icon_image_id' => 0,
],
[
'id' => 'news',
'admin_label' => 'Neuigkeiten aus Weimar',
'enabled' => true,
'title' => ['de_DE' => 'Neuigkeiten aus Weimar', 'en_US' => 'News from Weimar'],
'title_key' => 'homeNewsTitle',
'icon' => 'auto_stories',
'icon_image_id' => 0,
],
[
'id' => 'nearby',
'admin_label' => 'In deiner Nähe',
'enabled' => true,
'title' => ['de_DE' => 'In deiner Nähe', 'en_US' => 'Near you'],
'title_key' => 'homeNearbyTitle',
'icon' => 'near_me',
'icon_image_id' => 0,
],
[
'id' => 'listings',
'admin_label' => 'Neueste Angebote',
'enabled' => true,
'title' => ['de_DE' => 'Neueste Angebote', 'en_US' => 'Latest listings'],
'title_key' => 'homeListingsTitle',
'icon' => 'new_releases',
'icon_image_id' => 0,
],
[
'id' => 'community',
'admin_label' => 'Mit Weimar verbunden',
'enabled' => true,
'title' => ['de_DE' => 'Mit Weimar verbunden', 'en_US' => 'Connected to Weimar'],
'title_key' => 'homeCommunityTitle',
'icon' => 'people',
'icon_image_id' => 0,
],
];
}
private static function home_quick_access_defaults(): array
{
return [
['route' => 'places', 'category' => '', 'label' => ['de_DE' => 'Orte', 'en_US' => 'Places'], 'image_id' => 0],
['route' => 'events', 'category' => '', 'label' => ['de_DE' => 'Events', 'en_US' => 'Events'], 'image_id' => 0],
['route' => 'classifieds', 'category' => '', 'label' => ['de_DE' => 'Kleinanzeigen', 'en_US' => 'Classifieds'], 'image_id' => 0],
['route' => 'jobs', 'category' => '', 'label' => ['de_DE' => 'Jobs', 'en_US' => 'Jobs'], 'image_id' => 0],
['route' => 'real-estate', 'category' => '', 'label' => ['de_DE' => 'Immobilien', 'en_US' => 'Real estate'], 'image_id' => 0],
];
}
private static function normalize_home_quick_access_items($items): array
{
if (is_string($items)) {
$decoded = json_decode(wp_unslash($items), true);
$items = is_array($decoded) ? $decoded : [];
}
$normalized = [];
$definitions = ContentTypes::definitions();
foreach ((array) $items as $item) {
if (!is_array($item)) continue;
$route = sanitize_key((string) ($item['route'] ?? ''));
if (!$route || !isset($definitions[$route])) continue;
$category = sanitize_title((string) ($item['category'] ?? ''));
$taxonomy = sanitize_key((string) ($definitions[$route]['category'] ?? ''));
// A configured category must be an actual term of the selected
// route. This keeps the public contract free of invented slugs.
if ($category && function_exists('term_exists') && taxonomy_exists($taxonomy) && !term_exists($category, $taxonomy)) {
$category = '';
}
$normalized[] = [
'route' => $route,
'category' => $category,
'label' => self::normalize_localized_text($item['label'] ?? []),
'image_id' => self::image_id($item['image_id'] ?? 0),
];
}
return $normalized;
}
private static function normalize_home_sections($sections): array
{
if (is_string($sections)) {
$decoded = json_decode(wp_unslash($sections), true);
$sections = is_array($decoded) ? $decoded : [];
}
$defaults = [];
foreach (self::home_section_defaults() as $default) {
$defaults[$default['id']] = $default;
}
if (empty($sections)) {
return array_values($defaults);
}
$normalized = [];
$seen = [];
foreach ((array) $sections as $section) {
if (!is_array($section)) {
continue;
}
$id = sanitize_key((string) ($section['id'] ?? ''));
if (!$id || !isset($defaults[$id]) || isset($seen[$id])) {
continue;
}
$default = $defaults[$id];
$title = array_key_exists('title', $section)
? self::normalize_localized_text($section['title'])
: $default['title'];
$normalized[] = [
'id' => $id,
'admin_label' => $default['admin_label'],
'enabled' => filter_var($section['enabled'] ?? $default['enabled'], FILTER_VALIDATE_BOOLEAN),
'title' => $title,
'title_key' => preg_replace('/[^A-Za-z0-9_.-]/', '', (string) ($section['title_key'] ?? $default['title_key'])),
'icon' => sanitize_key((string) ($section['icon'] ?? $default['icon'])) ?: $default['icon'],
'icon_image_id' => self::image_id($section['icon_image_id'] ?? $default['icon_image_id']),
'items' => 'quick_access' === $id
? self::normalize_home_quick_access_items(
array_key_exists('items', $section) ? $section['items'] : ($default['items'] ?? [])
)
: [],
];
$seen[$id] = true;
}
foreach ($defaults as $id => $default) {
if (isset($seen[$id])) {
continue;
}
$normalized[] = $default;
}
return $normalized ?: array_values($defaults);
}
private static function normalize_navigation($navigation): array
{
$default_navigation = [];
foreach (self::defaults()['navigation'] as $default_item) {
$default_navigation[sanitize_key((string) $default_item['id'])] = $default_item;
$default_navigation[sanitize_text_field((string) $default_item['label'])] = $default_item;
}
if (is_string($navigation)) {
$decoded = json_decode($navigation, true);
$navigation = is_array($decoded) ? $decoded : [];
}
$normalized = [];
foreach ((array) $navigation as $index => $item) {
if (is_string($item)) {
$item = ['label' => $item];
}
if (!is_array($item)) {
continue;
}
$label = sanitize_text_field((string) ($item['label'] ?? ''));
if (!$label) {
continue;
}
$id_candidate = sanitize_key((string) ($item['id'] ?? ''));
$default_item = $default_navigation[$id_candidate] ?? $default_navigation[$label] ?? [];
$id = $id_candidate ?: sanitize_key((string) ($default_item['id'] ?? $label));
$route = sanitize_text_field((string) ($item['route'] ?? $default_item['route'] ?? '/'));
$label_key = preg_replace('/[^A-Za-z0-9_.-]/', '', (string) ($item['label_key'] ?? $default_item['label_key'] ?? ''));
// Older installs stored the pre-localized English default for
// the guide module. Normalize only that exact legacy default at
// the projection boundary; custom backend labels remain intact.
if ('guides' === $id && 'moduleGuides' === $label_key && in_array(strtolower($label), ['guides', 'guide'], true)) {
$label = __('Ratgeber', 'meinweimar-core');
}
$icon = sanitize_key((string) ($item['icon'] ?? $default_item['icon'] ?? 'grid_view')) ?: 'grid_view';
$icon_image_id = self::image_id($item['icon_image_id'] ?? $default_item['icon_image_id'] ?? 0);
$normalized[] = [
'id' => $id ?: 'item-' . (int) $index,
'label' => $label,
'label_key' => is_string($label_key) ? $label_key : '',
'icon' => $icon,
'icon_image_id' => $icon_image_id,
'route' => $route ?: '/',
'enabled' => filter_var($item['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN),
'primary' => filter_var($item['primary'] ?? ($default_item['primary'] ?? false), FILTER_VALIDATE_BOOLEAN),
];
}
return array_values($normalized);
}
private static function normalize_locales($locales): array
{
if (is_string($locales)) {
$decoded = json_decode($locales, true);
$locales = is_array($decoded) ? $decoded : preg_split('/[,\s]+/', $locales);
}
$normalized = [];
foreach ((array) $locales as $locale) {
if (!is_scalar($locale)) {
continue;
}
$locale = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $locale);
if ($locale && strlen($locale) <= 20) {
$normalized[] = $locale;
}
}
$normalized = array_values(array_unique($normalized));
return $normalized ?: self::defaults()['locales'];
}
private static function resolve_locale($value, array $locales, string $fallback): string
{
$candidate = str_replace('-', '_', preg_replace('/[^A-Za-z0-9_-]/', '', (string) $value));
foreach ($locales as $locale) {
if (0 === strcasecmp($candidate, (string) $locale)) {
return (string) $locale;
}
}
return $fallback;
}
private static function service_url($value): string
{
$value = trim((string) $value);
if (!preg_match('#^https?://#i', $value)) {
return '';
}
// WordPress URL sanitization strips curly-brace placeholders. Preserve
// the placeholders used by the provider-neutral tile/routing adapters
// while still passing the complete URL through esc_url_raw().
$placeholder_tokens = [
'{z}' => '__MEINWEIMAR_TILE_Z__',
'{x}' => '__MEINWEIMAR_TILE_X__',
'{y}' => '__MEINWEIMAR_TILE_Y__',
'{profile}' => '__MEINWEIMAR_ROUTE_PROFILE__',
'{coordinates}' => '__MEINWEIMAR_ROUTE_COORDINATES__',
'{lat}' => '__MEINWEIMAR_NAV_LAT__',
'{lng}' => '__MEINWEIMAR_NAV_LNG__',
'{destination}' => '__MEINWEIMAR_NAV_DESTINATION__',
'{address}' => '__MEINWEIMAR_NAV_ADDRESS__',
];
$sanitized_value = strtr($value, $placeholder_tokens);
$url = esc_url_raw($sanitized_value, ['http', 'https']);
$url = strtr($url, array_flip($placeholder_tokens));
return preg_match('#^https?://#i', $url) ? $url : '';
}
private static function navigation_provider($value): string
{
$provider = sanitize_key((string) $value);
return in_array($provider, ['google_maps', 'system'], true) ? $provider : 'google_maps';
}
private static function decimal($value, float $min, float $max, int $precision = 1): float
{
if (!is_numeric($value)) {
$value = $min;
}
return round(max($min, min($max, (float) $value)), $precision);
}
private static function sanitize_tile_styles($value): array
{
if (is_string($value)) {
$decoded = json_decode(wp_unslash($value), true);
$value = is_array($decoded) ? $decoded : [];
}
$styles = [];
foreach (['road', 'light', 'dark', 'satellite'] as $style) {
$styles[$style] = self::service_url(is_array($value) ? ($value[$style] ?? '') : '');
}
return $styles;
}
private static function image_id($value): int
{
$id = absint($value);
return $id && wp_attachment_is_image($id) ? $id : 0;
}
private static function logo_config(int $attachment_id): array
{
$url = $attachment_id ? (string) wp_get_attachment_image_url($attachment_id, 'full') : '';
if ('' === $url && $attachment_id) {
$url = (string) wp_get_attachment_url($attachment_id);
}
$dimensions = $attachment_id ? wp_get_attachment_image_src($attachment_id, 'full') : false;
return [
'id' => $attachment_id,
'url' => $url,
'app_url' => $attachment_id && is_string($url) && '' !== $url
? rest_url('meinweimar/v1/media/' . $attachment_id)
: '',
'srcset' => $attachment_id ? (string) wp_get_attachment_image_srcset($attachment_id, 'full') : '',
'alt' => $attachment_id ? (string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true) : '',
'width' => is_array($dimensions) ? absint($dimensions[1] ?? 0) : 0,
'height' => is_array($dimensions) ? absint($dimensions[2] ?? 0) : 0,
];
}
private static function sanitize_resource_markers($value): array
{
$value = is_array($value) ? $value : [];
$markers = [];
foreach (ContentTypes::definitions() as $route => $definition) {
if (!empty($definition['geo'])) {
$markers[$route] = self::image_id($value[$route] ?? 0);
}
}
return $markers;
}
public static function enqueue_admin_assets(string $hook): void
{
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
$marker_taxonomy = $screen && in_array((string) $screen->taxonomy, MapMarkers::taxonomies(), true);
if ('settings_page_meinweimar-core' !== $hook && !$marker_taxonomy) {
return;
}
wp_enqueue_media();
if ('settings_page_meinweimar-core' === $hook) {
wp_enqueue_style('meinweimar-admin-settings', URL . 'assets/css/admin-settings.css', [], VERSION);
}
wp_enqueue_script('meinweimar-admin-settings', URL . 'assets/js/admin-settings.js', ['jquery', 'jquery-ui-sortable'], VERSION, true);
}
}
