glanzlicht / MeinWeimar_APP

Official MeinWeimar App

Verified secure showcase
204 Files 2 MB Public size 14 Redacted Dart · PHP · XML · GITIGNORE · YAML · PLIST

Project overview

MeinWeimar_APP is a verified code showcase with 204 visible project entries across 2 top-level modules. The primary detected language is Dart; protected files remain visible in the structure while their contents stay private.

README

MeinWeimar — Flutter App & WordPress Plugin

> Working in Progress · Private portfolio repository · Active development

MeinWeimar is a location-based local platform for residents, students and visitors. This repository intentionally contains only two product parts:

  1. the Flutter consumer app;
  2. the meinweimar-core WordPress plugin that provides the editorial/admin
  3. layer and the REST contract consumed by the app.

The complete public website, WordPress core, databases, private infrastructure and unrelated platform modules are not part of this repository.

MeinWeimar consumer app preview

Why this project is interesting

MeinWeimar combines a mobile/web Flutter client with WordPress used as a structured content and administration backend. The goal is a maintainable product boundary: content, branding, navigation, categories, media and configuration are managed in WordPress and delivered through a versioned REST contract instead of being hardcoded in the Flutter UI.

The project demonstrates work across:

  • Flutter/Dart application architecture for iOS, Android and Web;
  • WordPress plugin architecture with custom post types, taxonomies, admin
  • settings and REST endpoints;

  • localized German/English UI and backend-controlled labels;
  • discovery/list/detail flows for places, services, events, classifieds, jobs
  • and real estate;

  • map, GPS, geocoding, marker and navigation contracts;
  • caching, ETag/config-version handling and offline-safe fallbacks;
  • role-aware account, claim, favorites, moderation and messaging boundaries;
  • automated Flutter tests, PHP/REST contract checks and release builds.

Repository structure

flutter-app/       Flutter consumer application
wordpress-plugin/  meinweimar-core WordPress plugin
screenshots/       Curated portfolio preview only

Current status

This is an active Working in Progress project. The core architecture and many end-to-end flows are implemented and continuously tested. Native device polish, production content, final legal/privacy review and further product modules are still being developed. The repository does not claim to be a finished production release.

Local development

Flutter app

cd flutter-app
flutter pub get
flutter analyze
flutter test
flutter run --dart-define=MW_API_BASE_URL=https://example.test/wp-json/meinweimar/v1

The API base URL and other environment-specific values are supplied at build or run time. No credentials are stored in the repository.

WordPress plugin

Copy wordpress-plugin into a WordPress installation's wp-content/plugins/meinweimar-core directory and activate it in the admin area. The plugin owns the MeinWeimar content types, editorial settings, permissions, REST responses, media/configuration contracts and frontend integration points.

Privacy and repository boundary

This repository is private and is prepared for controlled technical review. Secrets, production database exports, user data, WordPress uploads, private keys and infrastructure configuration must remain outside version control. The public-facing portfolio page uses a separate, redacted presentation layer and does not expose the private repository directly.

For technical reviewers

The most relevant entry points are:

  • flutter-app/lib/core — shared configuration, networking, localization,
  • theme and location contracts;

  • flutter-app/lib/features — consumer-facing product surfaces;
  • flutter-app/lib/repositories — API boundary and data access;
  • wordpress-plugin/includes — plugin services, REST, admin and contracts;
  • wordpress-plugin/assets — admin/frontend integration assets.

MeinWeimar is deliberately presented as an evolving product rather than a finished demo: the repository shows the architectural decisions, the working implementation and the remaining engineering work transparently.

wordpress-plugin/includes/class-admin-editor.phpPHP
<?php
declare(strict_types=1);
namespace MeinWeimar;
use WP_Post;
use WP_REST_Request;
final class AdminEditor
{
private const NONCE = 'meinweimar_editor_nonce';
/** @var list<string> */
private static array $pending_required_fields = [];
public static function hooks(): void
{
add_action('add_meta_boxes', [self::class, 'register_meta_boxes']);
add_action('admin_enqueue_scripts', [self::class, 'enqueue_assets']);
add_filter('wp_insert_post_data', [self::class, 'validate_required_fields_before_insert'], 10, 4);
foreach (['mw_classified', 'mw_job', 'mw_property'] as $post_type) {
add_filter(
'rest_pre_insert_' . $post_type,
[self::class, 'validate_required_fields_before_rest_insert'],
10,
2
);
}
add_filter('redirect_post_location', [self::class, 'redirect_validation_notice']);
add_action('admin_notices', [self::class, 'render_validation_notice']);
foreach (ContentTypes::public_post_types() as $post_type) {
add_filter('manage_' . $post_type . '_posts_columns', [self::class, 'admin_columns']);
add_action('manage_' . $post_type . '_posts_custom_column', [self::class, 'render_admin_column'], 10, 2);
}
foreach (ContentTypes::geo_post_types() as $post_type) {
add_action(
'save_post_' . $post_type,
'mw_place' === $post_type
? [self::class, 'save_place']
: ('mw_event' === $post_type ? [self::class, 'save_event'] : [self::class, 'save_geo']),
10,
2
);
}
foreach (['mw_news', 'mw_guide'] as $post_type) {
add_action('save_post_' . $post_type, [self::class, 'save_editorial'], 10, 2);
}
}
public static function register_meta_boxes(): void
{
add_meta_box(
'meinweimar_place_details',
__('MeinWeimar Place-Daten', 'meinweimar-core'),
[self::class, 'render_place_box'],
'mw_place',
'normal',
'high'
);
add_meta_box(
'meinweimar_event_details',
__('MeinWeimar Event-Daten', 'meinweimar-core'),
[self::class, 'render_event_box'],
'mw_event',
'normal',
'high'
);
foreach (['mw_service', 'mw_classified', 'mw_job', 'mw_property'] as $post_type) {
add_meta_box(
'meinweimar_' . $post_type . '_details',
__('MeinWeimar Geo- und Fachdaten', 'meinweimar-core'),
[self::class, 'render_geo_box'],
$post_type,
'normal',
'high'
);
}
foreach (['mw_news', 'mw_guide'] as $post_type) {
add_meta_box(
'meinweimar_' . $post_type . '_editorial',
__('MeinWeimar Redaktionsdaten', 'meinweimar-core'),
[self::class, 'render_editorial_box'],
$post_type,
'side',
'default'
);
}
}
public static function render_place_box(WP_Post $post): void
{
wp_nonce_field(self::NONCE, self::NONCE);
self::render_common_fields($post, 'places');
self::render_place_fields($post, 'places');
}
private static function render_place_fields(WP_Post $post, string $route): void
{
echo '<hr><h4>' . esc_html__('Place- und Einrichtungsdaten', 'meinweimar-core') . '</h4>';
self::textarea_field($post, 'mw_special_offers', __('Sonderangebote / Hinweise', 'meinweimar-core'));
self::field($post, 'mw_neighbourhood', __('Nachbarschaft / Umgebung', 'meinweimar-core'));
self::select_field($post, 'mw_featured', __('Hervorgehoben', 'meinweimar-core'), ['0' => __('Nein', 'meinweimar-core'), '1' => __('Ja', 'meinweimar-core')]);
self::field($post, 'mw_rating_average', __('Bewertung (Durchschnitt)', 'meinweimar-core'), 'number', ['min' => '0', 'max' => '5', 'step' => '0.1']);
self::field($post, 'mw_rating_count', __('Anzahl Bewertungen', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '1']);
self::select_field($post, 'mw_media_rights_status', __('Bildrechte-Status', 'meinweimar-core'), ['unknown' => __('Ungeprüft', 'meinweimar-core'), 'legacy-confirmed' => __('Legacy-Angabe vorhanden', 'meinweimar-core'), 'verified' => __('Geprüft', 'meinweimar-core')]);
self::select_field($post, 'mw_claim_status', __('Claim-Status', 'meinweimar-core'), ['unclaimed' => __('Nicht beansprucht', 'meinweimar-core'), 'claimed' => __('Beansprucht', 'meinweimar-core'), 'verified' => __('Verifiziert', 'meinweimar-core')]);
self::field($post, 'mw_map_style', __('Legacy-Kartenstil', 'meinweimar-core'));
self::field($post, 'mw_map_zoom', __('Kartenzoom', 'meinweimar-core'), 'number', ['min' => '1', 'max' => '20']);
if (FieldSchemas::has($route, 'mw_expire_date')) {
self::field($post, 'mw_expire_date', __('Ablaufdatum', 'meinweimar-core'), 'date');
}
}
public static function render_event_box(WP_Post $post): void
{
wp_nonce_field(self::NONCE, self::NONCE);
self::render_common_fields($post, 'events');
echo '<hr><h4>' . esc_html__('Event', 'meinweimar-core') . '</h4>';
self::field($post, 'mw_event_start', __('Beginn', 'meinweimar-core'), 'datetime-local');
self::field($post, 'mw_event_end', __('Ende', 'meinweimar-core'), 'datetime-local');
self::field($post, 'mw_venue_place_id', __('Venue-Place-ID', 'meinweimar-core'), 'number', ['min' => '0']);
self::select_field(
$post,
'mw_event_status',
__('Status', 'meinweimar-core'),
[
'' => __('Nicht gesetzt', 'meinweimar-core'),
'scheduled' => __('Geplant', 'meinweimar-core'),
'cancelled' => __('Abgesagt', 'meinweimar-core'),
'postponed' => __('Verschoben', 'meinweimar-core'),
'expired' => __('Abgelaufen', 'meinweimar-core'),
]
);
self::render_event_occurrences($post);
echo '<hr><h4>' . esc_html__('Tickets und Veranstalter', 'meinweimar-core') . '</h4><div class="mw-admin-grid">';
self::select_field($post, 'mw_event_ticket_mode', __('Ticketing', 'meinweimar-core'), [
'free' => __('Kostenlos / kein Ticket', 'meinweimar-core'),
'external' => __('Externer Ticketverkauf', 'meinweimar-core'),
'internal' => __('Ticketverkauf über MeinWeimar', 'meinweimar-core'),
'registration' => __('Anmeldung erforderlich', 'meinweimar-core'),
]);
self::field($post, 'mw_event_ticket_url', __('Ticket- oder Anmeldelink', 'meinweimar-core'), 'url');
self::field($post, 'mw_event_ticket_price_from', __('Ticketpreis ab', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_event_ticket_price_to', __('Ticketpreis bis', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_event_organizer_name', __('Veranstalter', 'meinweimar-core'));
self::field($post, 'mw_event_organizer_url', __('Veranstalter-Website', 'meinweimar-core'), 'url');
echo '</div>';
}
private static function render_event_occurrences(WP_Post $post): void
{
$rows = EventOccurrences::for_post($post->ID);
echo '<hr><div class="mw-event-occurrences" data-mw-event-occurrences><h4>' . esc_html__('Weitere Termine', 'meinweimar-core') . '</h4>';
echo '<p class="description">' . esc_html__('Optional: zusätzliche Termine für Veranstaltungsreihen. Der bestehende Einzeltermin bleibt als Rückwärtskompatibilität erhalten.', 'meinweimar-core') . '</p>';
echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Beginn', 'meinweimar-core') . '</th><th>' . esc_html__('Ende', 'meinweimar-core') . '</th><th>' . esc_html__('Zeitzone', 'meinweimar-core') . '</th><th>' . esc_html__('Status', 'meinweimar-core') . '</th><th>' . esc_html__('Wiederholung', 'meinweimar-core') . '</th><th></th></tr></thead><tbody data-mw-occurrence-rows>';
foreach ($rows as $index => $row) {
self::render_event_occurrence_row((int) $index, $row);
}
echo '</tbody></table>';
echo '<p><button type="button" class="button" data-mw-add-occurrence>' . esc_html__('Termin hinzufügen', 'meinweimar-core') . '</button></p>';
echo '<template data-mw-occurrence-template>';
self::render_event_occurrence_row('__INDEX__', ['starts_at' => '', 'ends_at' => '', 'timezone' => wp_timezone_string() ?: 'Europe/Berlin', 'status' => 'scheduled', 'recurrence' => 'none']);
echo '</template></div>';
}
/** @param int|string $index @param array<string, string> $row */
private static function render_event_occurrence_row($index, array $row): void
{
$value = static function (string $key) use ($row): string {
$raw = (string) ($row[$key] ?? '');
if (in_array($key, ['starts_at', 'ends_at'], true) && '' !== $raw) {
try {
$date = new \DateTimeImmutable($raw);
return $date->format('Y-m-d\\TH:i');
} catch (\Exception) {
return '';
}
}
return $raw;
};
$name = static fn (string $key): string => 'mw_event_occurrences[' . $index . '][' . $key . ']';
echo '<tr data-mw-occurrence-row><td><input type="datetime-local" class="widefat" name="' . esc_attr($name('starts_at')) . '" value="' . esc_attr($value('starts_at')) . '"></td>';
echo '<td><input type="datetime-local" class="widefat" name="' . esc_attr($name('ends_at')) . '" value="' . esc_attr($value('ends_at')) . '"></td>';
echo '<td><input type="text" class="widefat" name="' . esc_attr($name('timezone')) . '" value="' . esc_attr($value('timezone')) . '" placeholder="Europe/Berlin"></td>';
echo '<td><select class="widefat" name="' . esc_attr($name('status')) . '">';
foreach (['scheduled' => __('Geplant', 'meinweimar-core'), 'cancelled' => __('Abgesagt', 'meinweimar-core'), 'postponed' => __('Verschoben', 'meinweimar-core')] as $status => $label) {
echo '<option value="' . esc_attr($status) . '"' . selected($value('status'), $status, false) . '>' . esc_html($label) . '</option>';
}
echo '</select></td><td><input type="text" class="widefat" name="' . esc_attr($name('recurrence')) . '" value="' . esc_attr($value('recurrence')) . '" placeholder="none / weekly"></td>';
echo '<td><button type="button" class="button-link-delete" data-mw-remove-occurrence>' . esc_html__('Entfernen', 'meinweimar-core') . '</button></td></tr>';
}
public static function render_geo_box(WP_Post $post): void
{
wp_nonce_field(self::NONCE, self::NONCE);
$route = ContentTypes::route_for_post_type($post->post_type);
self::render_common_fields($post, $route);
if ('services' === $route) {
self::render_place_fields($post, $route);
return;
}
self::render_listing_fields($post, $route);
}
private static function render_listing_fields(WP_Post $post, string $route): void
{
echo '<hr><div class="mw-content-stepper" data-mw-content-stepper="listing" data-mw-content-route="' . esc_attr($route) . '">';
echo '<h4>' . esc_html__('Angebots- und Fachdaten', 'meinweimar-core') . '</h4>';
echo '<p class="description">' . esc_html__('Die Felder sind nach Angebotsart gruppiert. Abhängig vom Inhaltstyp werden nur passende Fachfelder angeboten; die Werte werden gemeinsam an WordPress, REST, Website und App ausgeliefert.', 'meinweimar-core') . '</p>';
echo '<div class="mw-content-stepper__step" data-mw-schema-step="basics">';
echo '<h5>' . esc_html__('1. Angebotsgrundlagen', 'meinweimar-core') . '</h5>';
echo '<div class="mw-admin-grid">';
$offer_options = match ($route) {
'classifieds' => [
'' => __('Bitte auswählen', 'meinweimar-core'),
'offer' => __('Ich biete an', 'meinweimar-core'),
'search' => __('Ich suche', 'meinweimar-core'),
],
'jobs' => [
'' => __('Bitte auswählen', 'meinweimar-core'),
'employment' => __('Stelle ausschreiben', 'meinweimar-core'),
],
default => [],
};
if ($offer_options && FieldSchemas::has($route, 'mw_listing_offer_type')) {
self::select_field($post, 'mw_listing_offer_type', 'jobs' === $route ? __('Stellentyp', 'meinweimar-core') : __('Inseratsart', 'meinweimar-core'), $offer_options);
}
if (FieldSchemas::has($route, 'mw_listing_provider_type')) {
$provider_options = match ($route) {
'classifieds' => [
'' => __('Bitte auswählen', 'meinweimar-core'),
'private' => __('Privat', 'meinweimar-core'),
'business' => __('Unternehmen', 'meinweimar-core'),
'organization' => __('Organisation', 'meinweimar-core'),
],
'jobs' => [
'' => __('Bitte auswählen', 'meinweimar-core'),
'employer' => __('Arbeitgeber', 'meinweimar-core'),
'business' => __('Unternehmen', 'meinweimar-core'),
'organization' => __('Organisation', 'meinweimar-core'),
],
default => [
'' => __('Bitte auswählen', 'meinweimar-core'),
'private' => __('Privat', 'meinweimar-core'),
'business' => __('Unternehmen', 'meinweimar-core'),
'organization' => __('Organisation', 'meinweimar-core'),
'broker' => __('Makler / Broker', 'meinweimar-core'),
],
};
self::select_field($post, 'mw_listing_provider_type', __('Anbieter', 'meinweimar-core'), $provider_options);
}
if (FieldSchemas::has($route, 'mw_listing_price_type')) {
$price_labels = [
'fixed' => __('Festpreis', 'meinweimar-core'),
'negotiable' => __('Verhandelbar', 'meinweimar-core'),
'on_request' => __('Auf Anfrage', 'meinweimar-core'),
'range' => __('Preisspanne', 'meinweimar-core'),
'free' => __('Zu verschenken', 'meinweimar-core'),
];
$price_options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach ((array) (FieldSchemas::for_route($route)['mw_listing_price_type']['constraints']['values'] ?? []) as $value) {
if (isset($price_labels[$value])) {
$price_options[$value] = $price_labels[$value];
}
}
self::select_field($post, 'mw_listing_price_type', 'jobs' === $route ? __('Vergütungsangabe', 'meinweimar-core') : __('Preisangabe', 'meinweimar-core'), $price_options);
}
if (FieldSchemas::has($route, 'mw_listing_price_amount')) {
self::field($post, 'mw_listing_price_amount', __('Betrag', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
}
if (FieldSchemas::has($route, 'mw_listing_price_currency')) {
self::select_field($post, 'mw_listing_price_currency', __('Währung', 'meinweimar-core'), ['EUR' => 'EUR', 'USD' => 'USD', 'GBP' => 'GBP']);
}
if (FieldSchemas::has($route, 'mw_listing_price_interval')) {
$interval_labels = [
'once' => __('Einmalig', 'meinweimar-core'),
'hour' => __('Pro Stunde', 'meinweimar-core'),
'month' => __('Pro Monat', 'meinweimar-core'),
'year' => __('Pro Jahr', 'meinweimar-core'),
];
$interval_options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach ((array) (FieldSchemas::for_route($route)['mw_listing_price_interval']['constraints']['values'] ?? []) as $value) {
if (isset($interval_labels[$value])) {
$interval_options[$value] = $interval_labels[$value];
}
}
self::select_field($post, 'mw_listing_price_interval', __('Preisintervall', 'meinweimar-core'), $interval_options);
}
if (FieldSchemas::has($route, 'mw_listing_available_from')) {
self::field($post, 'mw_listing_available_from', __('Verfügbar ab', 'meinweimar-core'), 'date');
}
echo '</div></div>';
if ('classifieds' === $route) {
echo '<div class="mw-content-stepper__step" data-mw-schema-step="classifieds">';
echo '<h5>' . esc_html__('2. Kleinanzeigen', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
self::select_field($post, 'mw_classified_condition', __('Zustand', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'new' => __('Neu', 'meinweimar-core'),
'used' => __('Gebraucht', 'meinweimar-core'),
'defective' => __('Defekt', 'meinweimar-core'),
]);
self::select_field($post, 'mw_classified_delivery_mode', __('Übergabe / Versand', 'meinweimar-core'), [
'pickup' => __('Abholung', 'meinweimar-core'),
'shipping' => __('Versand', 'meinweimar-core'),
'both' => __('Abholung oder Versand', 'meinweimar-core'),
'digital' => __('Digital', 'meinweimar-core'),
'none' => __('Keine Angabe', 'meinweimar-core'),
]);
echo '</div></div>';
echo '<div class="mw-content-stepper__step" data-mw-schema-step="classified-attributes">';
echo '<h5>' . esc_html__('3. Kategorieattribute', 'meinweimar-core') . '</h5><p class="description">' . esc_html__('Diese Angaben werden später je Kategorie weiter verfeinert. Unpassende Attribute bleiben leer und werden nicht ausgegeben.', 'meinweimar-core') . '</p><div class="mw-admin-grid">';
self::field($post, 'mw_classified_brand', __('Marke', 'meinweimar-core'));
self::field($post, 'mw_classified_model', __('Modell / Typ', 'meinweimar-core'));
self::field($post, 'mw_classified_color', __('Farbe', 'meinweimar-core'));
self::field($post, 'mw_classified_size', __('Größe / Maße', 'meinweimar-core'));
self::field($post, 'mw_classified_material', __('Material', 'meinweimar-core'));
self::field($post, 'mw_classified_item_age', __('Alter / Kaufjahr', 'meinweimar-core'));
self::field($post, 'mw_classified_original_price', __('Ursprünglicher Preis', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::select_field($post, 'mw_classified_payment_mode', __('Bevorzugte Zahlung', 'meinweimar-core'), [
'' => __('Nicht angegeben', 'meinweimar-core'),
'cash' => __('Barzahlung', 'meinweimar-core'),
'bank_transfer' => __('Überweisung', 'meinweimar-core'),
'secure_payment' => __('Sichere Online-Zahlung', 'meinweimar-core'),
'other' => __('Andere', 'meinweimar-core'),
]);
echo '</div></div>';
}
if ('jobs' === $route) {
echo '<div class="mw-content-stepper__step" data-mw-schema-step="jobs">';
echo '<h5>' . esc_html__('2. Stellenprofil', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
self::select_field($post, 'mw_job_employment_type', __('Beschäftigungsart', 'meinweimar-core'), [
'' => __('Bitte auswählen', 'meinweimar-core'),
'full_time' => __('Vollzeit', 'meinweimar-core'),
'part_time' => __('Teilzeit', 'meinweimar-core'),
'temporary' => __('Befristet', 'meinweimar-core'),
'freelance' => __('Freiberuflich', 'meinweimar-core'),
'apprenticeship' => __('Ausbildung', 'meinweimar-core'),
'internship' => __('Praktikum', 'meinweimar-core'),
]);
self::select_field($post, 'mw_job_work_mode', __('Arbeitsmodell', 'meinweimar-core'), [
'onsite' => __('Vor Ort', 'meinweimar-core'),
'hybrid' => __('Hybrid', 'meinweimar-core'),
'remote' => __('Fernarbeit', 'meinweimar-core'),
]);
self::field($post, 'mw_job_salary_min', __('Vergütung von', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_job_salary_max', __('Vergütung bis', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_job_application_url', __('Bewerbungs-URL', 'meinweimar-core'), 'url');
echo '</div></div>';
echo '<div class="mw-content-stepper__step" data-mw-schema-step="job-details">';
echo '<h5>' . esc_html__('3. Stellen- und Bewerbungsdetails', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
self::field($post, 'mw_job_reference_code', __('Referenznummer', 'meinweimar-core'));
self::field($post, 'mw_job_department', __('Abteilung / Team', 'meinweimar-core'));
self::field($post, 'mw_job_work_hours', __('Arbeitszeit / Schichtmodell', 'meinweimar-core'));
self::field($post, 'mw_job_start_date', __('Arbeitsbeginn', 'meinweimar-core'), 'date');
self::field($post, 'mw_job_application_deadline', __('Bewerbungsfrist', 'meinweimar-core'), 'date');
self::field($post, 'mw_job_education', __('Ausbildung / Abschluss', 'meinweimar-core'));
self::field($post, 'mw_job_experience_years', __('Erfahrung in Jahren', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.5']);
self::select_field($post, 'mw_job_application_method', __('Bewerbungsweg', 'meinweimar-core'), [
'' => __('Nicht angegeben', 'meinweimar-core'),
'url' => __('Bewerbungsseite', 'meinweimar-core'),
'email' => __('E-Mail', 'meinweimar-core'),
'form' => __('Formular', 'meinweimar-core'),
'phone' => __('Telefon', 'meinweimar-core'),
]);
self::field($post, 'mw_job_application_email', __('Bewerbungs-E-Mail', 'meinweimar-core'), 'email');
echo '</div>';
self::textarea_field($post, 'mw_job_responsibilities', __('Aufgaben / Verantwortlichkeiten', 'meinweimar-core'));
self::textarea_field($post, 'mw_job_requirements', __('Anforderungen / Qualifikationen', 'meinweimar-core'));
self::textarea_field($post, 'mw_job_benefits', __('Vorteile / Arbeitgeberleistungen', 'meinweimar-core'));
self::textarea_field($post, 'mw_job_languages', __('Sprachen', 'meinweimar-core'));
echo '</div>';
}
if ('real-estate' === $route) {
echo '<div class="mw-content-stepper__step" data-mw-schema-step="property">';
echo '<h5>' . esc_html__('2. Immobiliendaten', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
self::select_field($post, 'mw_property_transaction_type', __('Vermarktungsart', 'meinweimar-core'), [
'' => __('Bitte auswählen', 'meinweimar-core'),
'sale' => __('Kaufen', 'meinweimar-core'),
'rent' => __('Miete', 'meinweimar-core'),
]);
$property_kind_options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach (FieldSchemas::property_kind_options(self::category_slugs_for_route($post->ID, $route)) as $value => $label) {
$property_kind_options[$value] = __($label, 'meinweimar-core');
}
self::select_field($post, 'mw_property_kind', __('Immobilienart', 'meinweimar-core'), $property_kind_options);
self::field($post, 'mw_property_area', __('Wohn-/Nutzfläche in m²', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.1']);
self::select_field($post, 'mw_property_rooms', __('Zimmer', 'meinweimar-core'), self::property_room_options());
self::field($post, 'mw_property_rooms_custom', __('Eigene Zimmeranzahl', 'meinweimar-core'), 'number', ['min' => '0', 'max' => '100', 'step' => '0.1']);
self::select_field($post, 'mw_property_bathrooms', __('Bäder', 'meinweimar-core'), self::property_count_options());
echo '</div></div>';
echo '<div class="mw-content-stepper__step" data-mw-schema-step="property-details">';
echo '<h5>' . esc_html__('3. Objekt-, Preis- und Energiedaten', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
self::select_field($post, 'mw_property_bedrooms', __('Schlafzimmer', 'meinweimar-core'), self::property_count_options());
self::select_field($post, 'mw_property_floor', __('Etage', 'meinweimar-core'), self::property_floor_options());
self::field($post, 'mw_property_total_floors', __('Etagen insgesamt', 'meinweimar-core'), 'number', ['min' => '0', 'max' => '200', 'step' => '1']);
self::field($post, 'mw_property_year_built', __('Baujahr', 'meinweimar-core'), 'number', ['min' => '1000', 'max' => '2200', 'step' => '1']);
self::select_field($post, 'mw_property_condition', __('Zustand', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'new' => __('Neubau / neu', 'meinweimar-core'),
'first_occupancy' => __('Erstbezug', 'meinweimar-core'),
'renovated' => __('Renoviert / saniert', 'meinweimar-core'),
'well_kept' => __('Gepflegt', 'meinweimar-core'),
'needs_renovation' => __('Renovierungsbedürftig', 'meinweimar-core'),
]);
self::field($post, 'mw_property_last_renovated', __('Letzte Modernisierung', 'meinweimar-core'), 'number', ['min' => '1000', 'max' => '2200', 'step' => '1']);
self::field($post, 'mw_property_cold_rent', __('Kaltmiete', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_property_additional_costs', __('Nebenkosten', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::checkbox_field($post, 'mw_property_heating_costs_included', __('Heizkosten in Nebenkosten enthalten', 'meinweimar-core'));
self::field($post, 'mw_property_warm_rent', __('Warmmiete', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_property_deposit', __('Kaution', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_property_house_fee', __('Hausgeld', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
echo '<div class="mw-schema-dependent-group" data-mw-commission-group><h6>' . esc_html__('Provision', 'meinweimar-core') . '</h6><p class="description">' . esc_html__('Wähle verbindlich provisionsfrei oder provisionspflichtig. Bei einer Provision werden nur die zur Vermarktungsart passenden strukturierten Angaben eingeblendet.', 'meinweimar-core') . '</p>';
self::select_field($post, 'mw_property_commission_applicable', __('Provision fällig?', 'meinweimar-core'), [
'no' => __('Nein — provisionsfrei', 'meinweimar-core'),
'yes' => __('Ja — provisionspflichtig', 'meinweimar-core'),
]);
self::select_field($post, 'mw_property_commission_calculation', __('Berechnungsart', 'meinweimar-core'), [
'' => __('Bitte auswählen', 'meinweimar-core'),
'percentage' => __('Prozentsatz', 'meinweimar-core'),
'fixed' => __('Fester Betrag', 'meinweimar-core'),
'monthly_rent' => __('Monatliche Kaltmieten', 'meinweimar-core'),
]);
self::field($post, 'mw_property_commission_total', __('Gesamtprovision', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_property_commission_months', __('Anzahl Kaltmieten', 'meinweimar-core'), 'number', ['min' => '0', 'max' => '24', 'step' => '0.01']);
self::field($post, 'mw_property_commission_buyer_share', __('Anteil Käufer', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::field($post, 'mw_property_commission_seller_share', __('Anteil Verkäufer', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
self::select_field($post, 'mw_property_commission_vat', __('Umsatzsteuer', 'meinweimar-core'), [
'' => __('Bitte auswählen', 'meinweimar-core'),
'included' => __('Enthalten', 'meinweimar-core'),
'excluded' => __('Nicht enthalten', 'meinweimar-core'),
]);
self::select_field($post, 'mw_property_commission_payer', __('Zahlungspflichtig', 'meinweimar-core'), [
'' => __('Bitte auswählen', 'meinweimar-core'),
'buyer' => __('Käufer', 'meinweimar-core'),
'seller' => __('Verkäufer', 'meinweimar-core'),
'both' => __('Beide Parteien', 'meinweimar-core'),
'tenant' => __('Mieter', 'meinweimar-core'),
'landlord' => __('Vermieter', 'meinweimar-core'),
]);
echo '</div>';
self::select_field($post, 'mw_property_energy_certificate_type', __('Energieausweis', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'demand' => __('Bedarfsausweis', 'meinweimar-core'),
'consumption' => __('Verbrauchsausweis', 'meinweimar-core'),
'none' => __('Nicht vorhanden / nicht erforderlich', 'meinweimar-core'),
]);
self::field($post, 'mw_property_energy_value', __('Endenergiekennwert', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.1']);
self::select_field($post, 'mw_property_energy_class', __('Energieeffizienzklasse', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'A_PLUS' => 'A+', 'A' => 'A', 'B' => 'B', 'C' => 'C', 'D' => 'D', 'E' => 'E', 'F' => 'F', 'G' => 'G', 'H' => 'H',
]);
self::select_field($post, 'mw_property_heating_type', __('Heizungsart', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'district' => __('Fernwärme', 'meinweimar-core'), 'gas' => __('Gas', 'meinweimar-core'), 'oil' => __('Öl', 'meinweimar-core'),
'heat_pump' => __('Wärmepumpe', 'meinweimar-core'), 'electric' => __('Elektrisch', 'meinweimar-core'), 'wood' => __('Holz / Pellets', 'meinweimar-core'),
'solar' => __('Solar', 'meinweimar-core'), 'other' => __('Andere', 'meinweimar-core'),
]);
self::select_field($post, 'mw_property_energy_source', __('Energieträger', 'meinweimar-core'), [
'unknown' => __('Nicht angegeben', 'meinweimar-core'), 'district' => __('Fernwärme', 'meinweimar-core'), 'gas' => __('Gas', 'meinweimar-core'),
'oil' => __('Öl', 'meinweimar-core'), 'electric' => __('Strom', 'meinweimar-core'), 'wood' => __('Holz / Pellets', 'meinweimar-core'),
'solar' => __('Solar', 'meinweimar-core'), 'other' => __('Andere', 'meinweimar-core'),
]);
echo '</div></div>';
echo '<div class="mw-content-stepper__step" data-mw-schema-step="property-amenities">';
echo '<h5>' . esc_html__('4. Ausstattung und Goodies', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
foreach ([
'mw_property_amenity_kitchen' => __('Einbauküche', 'meinweimar-core'),
'mw_property_amenity_balcony' => __('Balkon / Loggia', 'meinweimar-core'),
'mw_property_amenity_terrace' => __('Terrasse', 'meinweimar-core'),
'mw_property_amenity_garden' => __('Garten', 'meinweimar-core'),
'mw_property_amenity_basement' => __('Keller', 'meinweimar-core'),
'mw_property_amenity_attic' => __('Dachboden', 'meinweimar-core'),
'mw_property_amenity_elevator' => __('Aufzug', 'meinweimar-core'),
'mw_property_amenity_guest_toilet' => __('Gäste-WC', 'meinweimar-core'),
'mw_property_amenity_air_conditioning' => __('Klimaanlage', 'meinweimar-core'),
'mw_property_amenity_floor_heating' => __('Fußbodenheizung', 'meinweimar-core'),
'mw_property_amenity_fireplace' => __('Kamin / Ofen', 'meinweimar-core'),
'mw_property_amenity_furnished' => __('Möbliert', 'meinweimar-core'),
'mw_property_amenity_barrier_free' => __('Barrierearm / rollstuhlgerecht', 'meinweimar-core'),
'mw_property_amenity_pets_allowed' => __('Haustiere erlaubt', 'meinweimar-core'),
'mw_property_amenity_garage' => __('Garage / Tiefgarage', 'meinweimar-core'),
'mw_property_amenity_carport' => __('Carport', 'meinweimar-core'),
'mw_property_amenity_e_charging' => __('E-Ladepunkt', 'meinweimar-core'),
] as $key => $label) {
self::checkbox_field($post, $key, $label);
}
self::field($post, 'mw_property_parking_spaces', __('Stellplätze', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '1']);
self::field($post, 'mw_property_parking_price', __('Stellplatzpreis', 'meinweimar-core'), 'number', ['min' => '0', 'step' => '0.01']);
echo '</div></div>';
}
echo '<div class="mw-content-stepper__step" data-mw-schema-step="legacy">';
echo '<h5>' . esc_html__('Kompatibilität und Veröffentlichung', 'meinweimar-core') . '</h5><div class="mw-admin-grid">';
if (FieldSchemas::has($route, 'mw_listing_status')) {
self::field($post, 'mw_listing_status', __('Angebotsstatus', 'meinweimar-core'), 'text', ['placeholder' => 'active']);
}
if (FieldSchemas::has($route, 'mw_contact_preference')) {
self::field($post, 'mw_contact_preference', __('Kontaktpräferenz', 'meinweimar-core'), 'text', ['placeholder' => 'messaging']);
}
if (FieldSchemas::has($route, 'mw_price_label')) {
self::field($post, 'mw_price_label', __('Preis-/Vergütungstext', 'meinweimar-core'));
}
if (FieldSchemas::has($route, 'mw_company_name')) {
self::field($post, 'mw_company_name', __('Organisation oder Unternehmen', 'meinweimar-core'));
}
if (FieldSchemas::has($route, 'mw_job_featured')) {
self::select_field($post, 'mw_job_featured', __('Top-Job', 'meinweimar-core'), [
'0' => __('Nein', 'meinweimar-core'),
'1' => __('Ja', 'meinweimar-core'),
]);
}
if (FieldSchemas::has($route, 'mw_expire_date')) {
self::field($post, 'mw_expire_date', __('Ablaufdatum', 'meinweimar-core'), 'date');
}
echo '</div></div></div>';
}
public static function render_editorial_box(WP_Post $post): void
{
wp_nonce_field(self::NONCE, self::NONCE);
echo '<p class="description">' . esc_html__('Locale und redaktionelle Inhalte werden von Website und App gemeinsam verwendet.', 'meinweimar-core') . '</p>';
self::locale_field($post);
self::render_gallery_field($post);
}
private static function render_common_fields(WP_Post $post, string $route): void
{
echo '<p class="description">' . esc_html__('Diese Felder sind Teil des gemeinsamen WordPress-/REST-Datenvertrags. Sichtbare Oberflächen werden später über Templates, Elementor oder Flutter umgesetzt.', 'meinweimar-core') . '</p>';
echo '<div class="mw-admin-grid">';
self::field($post, 'mw_address_line1', __('Adresse', 'meinweimar-core'));
self::field($post, 'mw_address_line2', __('Adresszusatz', 'meinweimar-core'));
self::field($post, 'mw_postal_code', __('PLZ', 'meinweimar-core'));
self::field($post, 'mw_locality', __('Ort', 'meinweimar-core'));
self::field($post, 'mw_region', __('Region', 'meinweimar-core'));
self::field($post, 'mw_country', __('Land', 'meinweimar-core'), 'text', ['placeholder' => 'DE']);
self::field($post, 'mw_phone', __('Telefon', 'meinweimar-core'), 'tel');
self::field($post, 'mw_email', __('E-Mail', 'meinweimar-core'), 'email');
self::field($post, 'mw_website_url', __('Website', 'meinweimar-core'), 'url');
self::field($post, 'mw_facebook_url', __('Facebook-URL', 'meinweimar-core'), 'url');
self::field($post, 'mw_twitter_url', __('Twitter/X-URL', 'meinweimar-core'), 'url');
self::field($post, 'mw_video_url', __('Video-URL', 'meinweimar-core'), 'url');
self::field($post, 'mw_geo_latitude', __('Breitengrad', 'meinweimar-core'), 'number', ['step' => 'any', 'min' => '-90', 'max' => '90']);
self::field($post, 'mw_geo_longitude', __('Längengrad', 'meinweimar-core'), 'number', ['step' => 'any', 'min' => '-180', 'max' => '180']);
self::field($post, 'mw_geo_precision', __('Geo-Genauigkeit', 'meinweimar-core'), 'text', ['placeholder' => 'exact']);
self::select_field(
$post,
'mw_geo_source',
__('Geo-Quelle', 'meinweimar-core'),
[
'unknown' => __('Unbekannt', 'meinweimar-core'),
'manual' => __('Manuell geprüft', 'meinweimar-core'),
'geocoder' => __('Geocoding-Dienst', 'meinweimar-core'),
'import' => __('Kontrollierter Import', 'meinweimar-core'),
'legacy' => __('Legacy-Referenz', 'meinweimar-core'),
]
);
self::locale_field($post);
echo '</div>';
self::select_field(
$post,
'mw_geo_privacy',
__('Geo-Privacy', 'meinweimar-core'),
[
'public' => __('Öffentlich — genaue Position', 'meinweimar-core'),
'approximate' => __('Ungefähr — Position wird gerundet', 'meinweimar-core'),
'private' => __('Privat — Position nicht öffentlich ausgeben', 'meinweimar-core'),
]
);
if (FieldSchemas::has($route, 'mw_opening_hours')) {
self::textarea_field($post, 'mw_opening_hours', __('Öffnungszeiten', 'meinweimar-core'));
}
self::render_gallery_field($post);
if (ImageSuggestions::supports_target_type($route)) {
self::media_placeholder_field($post);
}
}
public static function enqueue_assets(): void
{
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || !in_array((string) $screen->post_type, array_values(ContentTypes::public_post_types()), true)) {
return;
}
wp_enqueue_media();
wp_enqueue_script('meinweimar-admin-editor', URL . 'assets/js/admin-editor.js', ['jquery'], VERSION, true);
$term_slugs = [];
foreach (FieldSchemas::category_admin_contract() as $rule) {
$taxonomy = (string) ($rule['taxonomy'] ?? '');
if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
continue;
}
$terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false]);
if (is_wp_error($terms)) {
continue;
}
$term_slugs[$taxonomy] = [];
foreach ($terms as $term) {
$term_slugs[$taxonomy][(string) $term->term_id] = (string) $term->slug;
}
}
wp_localize_script('meinweimar-admin-editor', 'MeinWeimarAdminEditor', [
'categoryRules' => FieldSchemas::category_admin_contract(),
'termSlugs' => $term_slugs,
]);
}
public static function admin_columns(array $columns): array
{
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
$post_type = $screen ? (string) $screen->post_type : '';
$is_geo = in_array($post_type, ContentTypes::geo_post_types(), true);
$is_event = 'mw_event' === $post_type;
$result = [];
foreach ($columns as $key => $label) {
$result[$key] = $label;
if ('title' === $key) {
$result['mw_content_locale'] = __('Locale', 'meinweimar-core');
if ($is_geo) {
$result['mw_geo_status'] = __('Geo', 'meinweimar-core');
$result['mw_listing_status'] = __('Status', 'meinweimar-core');
}
if ($is_event) {
$result['mw_event_date'] = __('Eventdatum', 'meinweimar-core');
}
}
}
return $result;
}
public static function render_admin_column(string $column, int $post_id): void
{
switch ($column) {
case 'mw_content_locale':
echo esc_html((string) (get_post_meta($post_id, 'mw_content_locale', true) ?: '—'));
break;
case 'mw_geo_status':
$post_type = (string) get_post_type($post_id);
if (!in_array($post_type, ContentTypes::geo_post_types(), true)) {
echo esc_html__('—', 'meinweimar-core');
break;
}
$privacy = (string) (get_post_meta($post_id, 'mw_geo_privacy', true) ?: 'public');
$latitude = get_post_meta($post_id, 'mw_geo_latitude', true);
$longitude = get_post_meta($post_id, 'mw_geo_longitude', true);
$available = '' !== (string) $latitude && '' !== (string) $longitude;
printf(
'%s · %s',
esc_html($available ? __('vorhanden', 'meinweimar-core') : __('fehlt', 'meinweimar-core')),
esc_html($privacy)
);
break;
case 'mw_listing_status':
$post_type = (string) get_post_type($post_id);
if (!in_array($post_type, ContentTypes::geo_post_types(), true)) {
echo esc_html__('—', 'meinweimar-core');
break;
}
echo esc_html((string) (get_post_meta($post_id, 'mw_listing_status', true) ?: '—'));
break;
case 'mw_event_date':
if ('mw_event' !== get_post_type($post_id)) {
echo esc_html__('—', 'meinweimar-core');
break;
}
echo esc_html((string) (get_post_meta($post_id, 'mw_event_start', true) ?: '—'));
break;
}
}
private static function field(WP_Post $post, string $key, string $label, string $type = 'text', array $attributes = []): void
{
$value = get_post_meta($post->ID, $key, true);
[$visible, $required] = self::field_render_state($post, $key);
if ($required && !array_key_exists('required', $attributes)) {
$attributes['required'] = 'required';
}
$attribute_string = '';
foreach ($attributes as $attribute => $attribute_value) {
$attribute_string .= ' ' . esc_attr($attribute) . '="' . esc_attr((string) $attribute_value) . '"';
}
echo '<p' . ($visible ? '' : ' hidden') . '><label for="' . esc_attr($key) . '"><strong>' . esc_html($label) . '</strong></label><br>';
echo '<input class="widefat" data-mw-field-key="' . esc_attr($key) . '" id="' . esc_attr($key) . '" name="' . esc_attr($key) . '" type="' . esc_attr($type) . '" value="' . esc_attr((string) $value) . '"' . $attribute_string . '></p>';
}
private static function textarea_field(WP_Post $post, string $key, string $label): void
{
$value = get_post_meta($post->ID, $key, true);
[$visible, $required] = self::field_render_state($post, $key);
echo '<p' . ($visible ? '' : ' hidden') . '><label for="' . esc_attr($key) . '"><strong>' . esc_html($label) . '</strong></label><br>';
echo '<textarea class="widefat" data-mw-field-key="' . esc_attr($key) . '" rows="3" id="' . esc_attr($key) . '" name="' . esc_attr($key) . '"' . ($required ? ' required="required"' : '') . '>' . esc_textarea((string) $value) . '</textarea></p>';
}
private static function select_field(WP_Post $post, string $key, string $label, array $options): void
{
$current = (string) get_post_meta($post->ID, $key, true);
if ('mw_property_transaction_type' === $key) {
// Render legacy lease/temporary storage with the current public
// rent choice without rewriting metadata during a read.
$current = (string) FieldSchemas::sanitize_value($key, $current, 'real-estate');
}
[$visible, $required] = self::field_render_state($post, $key);
echo '<p' . ($visible ? '' : ' hidden') . '><label for="' . esc_attr($key) . '"><strong>' . esc_html($label) . '</strong></label><br><select class="widefat" data-mw-field-key="' . esc_attr($key) . '" id="' . esc_attr($key) . '" name="' . esc_attr($key) . '"' . ($required ? ' required="required"' : '') . '>';
foreach ($options as $value => $text) {
echo '<option value="' . esc_attr($value) . '"' . selected($current, $value, false) . '>' . esc_html($text) . '</option>';
}
echo '</select></p>';
}
/** @return array<string,string> */
private static function property_room_options(): array
{
$options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach (['1', '1.5', '2', '2.5', '3', '3.5', '4', '4.5', '5', '5.5', '6'] as $value) {
$options[$value] = $value;
}
$options['custom'] = __('Eigene Anzahl', 'meinweimar-core');
return $options;
}
/** @return array<string,string> */
private static function property_count_options(): array
{
$options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach (['0', '1', '2', '3', '4', '5', '6'] as $value) {
$options[$value] = $value;
}
$options['more_than_6'] = __('Mehr als 6', 'meinweimar-core');
return $options;
}
/** @return array<string,string> */
private static function property_floor_options(): array
{
$options = ['' => __('Bitte auswählen', 'meinweimar-core')];
foreach (range(1, 15) as $value) {
$options[(string) $value] = (string) $value;
}
$options['other'] = __('Andere', 'meinweimar-core');
return $options;
}
private static function checkbox_field(WP_Post $post, string $key, string $label): void
{
[$visible, $required] = self::field_render_state($post, $key);
$value = FieldSchemas::sanitize_value($key, get_post_meta($post->ID, $key, true), 'real-estate');
echo '<p' . ($visible ? '' : ' hidden') . '><label><input type="hidden" name="' . esc_attr($key) . '" value="0">';
echo '<input type="checkbox" data-mw-field-key="' . esc_attr($key) . '" id="' . esc_attr($key) . '" name="' . esc_attr($key) . '" value="1"' . checked('1', $value, false) . ($required ? ' required="required"' : '') . '> ' . esc_html($label) . '</label></p>';
}
/**
* Resolve the initial editor state from the canonical schema. JavaScript
* keeps this state interactive, but the first render must already hide
* category-inapplicable fields and mark category-required fields. This
* also keeps the editor usable when admin JavaScript is unavailable.
*
* @return array{0: bool, 1: bool}
*/
private static function field_render_state(WP_Post $post, string $key): array
{
$route = ContentTypes::route_for_post_type($post->post_type);
if (!FieldSchemas::has($route, $key)) {
return [true, false];
}
$category_slugs = self::category_slugs_for_route($post->ID, $route);
$values = FieldSchemas::values_for_post($post, $route);
$visible = FieldSchemas::field_applies($route, $key, $category_slugs, $values);
$required = $visible && in_array(
$key,
FieldSchemas::required_fields_for_categories($route, $category_slugs, $values),
true
);
return [$visible, $required];
}
private static function tri_state_options(): array
{
return [
'unknown' => __('Nicht angegeben', 'meinweimar-core'),
'yes' => __('Ja', 'meinweimar-core'),
'no' => __('Nein', 'meinweimar-core'),
];
}
private static function locale_field(WP_Post $post): void
{
$current = Settings::sanitize_locale(get_post_meta($post->ID, 'mw_content_locale', true));
$locales = (array) (Settings::public_config()['localization']['available_locales'] ?? []);
echo '<p><label for="mw_content_locale"><strong>' . esc_html__('Inhalts-Locale', 'meinweimar-core') . '</strong></label><br>';
echo '<select class="widefat" id="mw_content_locale" name="mw_content_locale">';
echo '<option value="">' . esc_html__('Unmarkiert — zentrale Fallback-Auswahl', 'meinweimar-core') . '</option>';
foreach ($locales as $locale) {
echo '<option value="' . esc_attr((string) $locale) . '"' . selected($current, $locale, false) . '>' . esc_html((string) $locale) . '</option>';
}
echo '</select></p>';
}
private static function render_gallery_field(WP_Post $post): void
{
$ids = ContentTypes::sanitize_gallery_ids(get_post_meta($post->ID, 'mw_gallery_ids', true));
echo '<p><strong>' . esc_html__('Galerie / weitere Bilder', 'meinweimar-core') . '</strong><br>';
echo '<input type="hidden" id="mw-gallery-ids" name="mw_gallery_ids" value="' . esc_attr(implode(',', $ids)) . '">';
echo '<button type="button" class="button" id="mw-select-gallery">' . esc_html__('Bilder aus Mediathek wählen', 'meinweimar-core') . '</button> ';
echo '<span id="mw-gallery-preview">' . esc_html($ids ? sprintf(__('%d Bilder ausgewählt', 'meinweimar-core'), count($ids)) : __('Noch keine Bilder ausgewählt', 'meinweimar-core')) . '</span></p>';
echo '<p class="description">' . esc_html__('Die Reihenfolge wird an Website, Elementor und App gemeinsam ausgeliefert. Alternativ bleibt das Beitragsbild die Preview.', 'meinweimar-core') . '</p>';
}
private static function media_placeholder_field(WP_Post $post): void
{
self::select_field(
$post,
'mw_media_placeholder',
__('Primärbild ist Platzhalter', 'meinweimar-core'),
[
'0' => __('Nein — Bild ist redaktionell verwendbar', 'meinweimar-core'),
'1' => __('Ja — Bild bleibt sichtbar, Vorschlag erlauben', 'meinweimar-core'),
]
);
}
public static function save_place(int $post_id, WP_Post $post): void
{
if (!self::can_save($post_id, $post)) {
return;
}
self::save_common_fields($post_id, 'places');
self::save_place_fields($post_id, 'places');
Geocoding::sync_post($post_id);
}
public static function save_event(int $post_id, WP_Post $post): void
{
if (!self::can_save($post_id, $post)) {
return;
}
self::save_common_fields($post_id, 'events');
foreach (FieldSchemas::editor_for_route('events', 'event') as $key => $field) {
if (!array_key_exists($key, $_POST)) {
continue;
}
self::save_field($post_id, $key, FieldSchemas::sanitize_value($key, wp_unslash($_POST[$key] ?? ''), 'events'));
}
$occurrences = EventOccurrences::sanitize_rows(wp_unslash($_POST[EventOccurrences::META_KEY] ?? []));
if ($occurrences) {
update_post_meta($post_id, EventOccurrences::META_KEY, $occurrences);
} else {
delete_post_meta($post_id, EventOccurrences::META_KEY);
}
Geocoding::sync_post($post_id);
}
public static function save_geo(int $post_id, WP_Post $post): void
{
if (!self::can_save($post_id, $post)) {
return;
}
$route = ContentTypes::route_for_post_type($post->post_type);
self::save_common_fields($post_id, $route);
if ('services' === $route) {
self::save_place_fields($post_id, $route);
} else {
self::save_listing_fields($post_id, $route);
}
Geocoding::sync_post($post_id);
}
public static function save_editorial(int $post_id, WP_Post $post): void
{
if (!self::can_save($post_id, $post)) {
return;
}
self::save_field($post_id, 'mw_content_locale', Settings::sanitize_locale(wp_unslash($_POST['mw_content_locale'] ?? '')));
self::save_gallery($post_id);
}
private static function save_common_fields(int $post_id, string $route): void
{
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['content', 'address', 'contact', 'geo', 'hours'], true)) {
continue;
}
if (in_array($key, ['mw_gallery_ids', 'mw_media_placeholder'], true)) {
continue;
}
if (!array_key_exists($key, $_POST)) {
continue;
}
$raw = wp_unslash($_POST[$key] ?? '');
self::save_field($post_id, $key, FieldSchemas::sanitize_value($key, $raw, $route));
}
if (ImageSuggestions::supports_target_type($route)) {
self::save_field($post_id, 'mw_media_placeholder', self::allowed_value($_POST['mw_media_placeholder'] ?? '0', ['0', '1']));
}
self::save_gallery($post_id);
}
private static function save_place_fields(int $post_id, string $route): void
{
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['place_details', 'lifecycle'], true)) {
continue;
}
if (!array_key_exists($key, $_POST)) {
continue;
}
self::save_field($post_id, $key, FieldSchemas::sanitize_value($key, wp_unslash($_POST[$key] ?? ''), $route));
}
}
/**
* Resolve the assigned category slugs for a route before persisting
* category-dependent fields. Empty assignments remain permissive so that
* legacy/imported posts are not silently stripped before categorisation.
*
* @return list<string>
*/
private static function category_slugs_for_route(int $post_id, string $route): array
{
$contract = FieldSchemas::category_admin_contract()[$route] ?? [];
$taxonomy = (string) ($contract['taxonomy'] ?? '');
if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
return [];
}
$terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'slugs']);
if (is_wp_error($terms)) {
return [];
}
return array_values(array_filter(array_map('sanitize_title', (array) $terms)));
}
/**
* Resolve category slugs from the pending classic-editor request. The
* taxonomy metabox is saved after wp_insert_post_data runs, so validation
* must inspect the submitted term IDs/slugs first. An explicitly empty
* taxonomy submission means that categories are being cleared and must not
* silently fall back to the post's previous terms.
*
* @return list<string>
*/
private static function category_slugs_for_insert(int $post_id, string $route): array
{
$contract = FieldSchemas::category_admin_contract()[$route] ?? [];
$taxonomy = (string) ($contract['taxonomy'] ?? '');
if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
return [];
}
$tax_input = isset($_POST['tax_input']) && is_array($_POST['tax_input'])
? $_POST['tax_input']
: [];
if (!array_key_exists($taxonomy, $tax_input)) {
return $post_id > 0 ? self::category_slugs_for_route($post_id, $route) : [];
}
return self::normalize_category_slugs($taxonomy, $tax_input[$taxonomy]);
}
/**
* Resolve submitted term IDs or slugs through the same taxonomy contract
* for classic-editor and REST saves.
*
* @return list<string>
*/
private static function normalize_category_slugs(string $taxonomy, $submitted): array
{
$submitted = is_array($submitted) ? $submitted : [$submitted];
$slugs = [];
foreach ($submitted as $value) {
$value = is_scalar($value) ? (string) wp_unslash($value) : '';
if ('' === trim($value)) {
continue;
}
$term = is_numeric($value)
? get_term(absint($value), $taxonomy)
: get_term_by('slug', sanitize_title($value), $taxonomy);
if ($term && !is_wp_error($term)) {
$slugs[] = sanitize_title((string) $term->slug);
}
}
return array_values(array_unique(array_filter($slugs)));
}
/**
* Resolve category terms from a Core REST request before WordPress writes
* the taxonomy. An omitted taxonomy falls back to existing terms on an
* update; an explicit empty list means categories are being cleared.
*
* @return list<string>
*/
private static function category_slugs_for_rest_insert(int $post_id, string $route, WP_REST_Request $request): array
{
$contract = FieldSchemas::category_admin_contract()[$route] ?? [];
$taxonomy = (string) ($contract['taxonomy'] ?? '');
if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
return [];
}
if (!$request->has_param($taxonomy)) {
return $post_id > 0 ? self::category_slugs_for_route($post_id, $route) : [];
}
return self::normalize_category_slugs($taxonomy, $request->get_param($taxonomy));
}
/**
* Build the same sanitized listing values used by save_listing_fields()
* before WordPress decides whether the post may become public.
*
* @return array<string, mixed>
*/
private static function listing_values_for_insert(int $post_id, string $route): array
{
$values = [];
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['listing', 'lifecycle'], true)) {
continue;
}
$raw = array_key_exists($key, $_POST)
? wp_unslash($_POST[$key] ?? '')
: ($post_id > 0 ? get_post_meta($post_id, $key, true) : '');
$values[$key] = FieldSchemas::sanitize_value($key, $raw, $route);
}
return $values;
}
/**
* Build sanitized listing values from Core REST meta parameters before
* the REST controller persists them. Existing metadata remains valid for
* partial updates, matching the classic-editor save contract.
*
* @return array<string, mixed>
*/
private static function listing_values_for_rest_insert(int $post_id, string $route, WP_REST_Request $request): array
{
$meta = $request->get_param('meta');
$meta = is_array($meta) ? $meta : [];
$values = [];
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['listing', 'lifecycle'], true)) {
continue;
}
if (array_key_exists($key, $meta)) {
$raw = $meta[$key];
} elseif ($request->has_param($key)) {
$raw = $request->get_param($key);
} else {
$raw = $post_id > 0 ? get_post_meta($post_id, $key, true) : '';
}
$values[$key] = FieldSchemas::sanitize_value($key, $raw, $route);
}
return $values;
}
/**
* Prevent an authenticated classic-editor publish from bypassing
* category-dependent required fields. Draft saves remain possible so the
* editor can complete the object; the existing redirect notice explains
* which fields are missing. Core REST publish transitions use the
* adjacent REST pre-insert gate below.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $postarr
* @param array<string, mixed> $unsanitized_postarr
* @return array<string, mixed>
*/
public static function validate_required_fields_before_insert(array $data, array $postarr, array $unsanitized_postarr, bool $update): array
{
unset($unsanitized_postarr, $update);
if ((defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) || 'publish' !== (string) ($data['post_status'] ?? '')) {
return $data;
}
$nonce = isset($_POST[self::NONCE]) ? sanitize_text_field(wp_unslash($_POST[self::NONCE])) : '';
if ('' === $nonce || !wp_verify_nonce($nonce, self::NONCE)) {
return $data;
}
$post_type = (string) ($data['post_type'] ?? $postarr['post_type'] ?? '');
$route = ContentTypes::route_for_post_type($post_type);
if (!in_array($route, ['classifieds', 'jobs', 'real-estate'], true)) {
return $data;
}
$post_id = absint($postarr['ID'] ?? 0);
if ($post_id > 0) {
if (!current_user_can('edit_post', $post_id)) {
return $data;
}
} elseif (!current_user_can('edit_posts')) {
return $data;
}
$category_slugs = self::category_slugs_for_insert($post_id, $route);
if (!$category_slugs) {
return $data;
}
$missing = FieldSchemas::required_fields_for_categories(
$route,
$category_slugs,
self::listing_values_for_insert($post_id, $route)
);
if (!$missing) {
return $data;
}
self::$pending_required_fields = array_values(array_unique(array_merge(self::$pending_required_fields, $missing)));
$data['post_status'] = 'draft';
return $data;
}
/**
* Keep Core REST publishing aligned with the classic editor. The REST
* controller handles authentication and persistence; this filter only
* downgrades an otherwise-public listing to draft when its selected
* category requires missing schema fields.
*
* @param object|WP_Error $prepared_post
* @return object|WP_Error
*/
public static function validate_required_fields_before_rest_insert($prepared_post, WP_REST_Request $request)
{
if (is_wp_error($prepared_post) || !is_object($prepared_post) || 'publish' !== (string) ($prepared_post->post_status ?? '')) {
return $prepared_post;
}
$post_type = (string) ($prepared_post->post_type ?? '');
$route = ContentTypes::route_for_post_type($post_type);
if (!in_array($route, ['classifieds', 'jobs', 'real-estate'], true)) {
return $prepared_post;
}
$post_id = absint($prepared_post->ID ?? 0);
if ($post_id > 0 ? !current_user_can('edit_post', $post_id) : !current_user_can('edit_posts')) {
return $prepared_post;
}
$category_slugs = self::category_slugs_for_rest_insert($post_id, $route, $request);
if (!$category_slugs) {
return $prepared_post;
}
$missing = FieldSchemas::required_fields_for_categories(
$route,
$category_slugs,
self::listing_values_for_rest_insert($post_id, $route, $request)
);
if ($missing) {
$prepared_post->post_status = 'draft';
}
return $prepared_post;
}
private static function save_listing_fields(int $post_id, string $route): void
{
$category_slugs = self::category_slugs_for_route($post_id, $route);
$values = [];
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['listing', 'lifecycle'], true)) {
continue;
}
$raw = array_key_exists($key, $_POST)
? wp_unslash($_POST[$key] ?? '')
: ('boolean01' === ($field['input'] ?? '') ? '0' : get_post_meta($post_id, $key, true));
$values[$key] = FieldSchemas::sanitize_value($key, $raw, $route);
}
foreach (FieldSchemas::for_route($route) as $key => $field) {
if (!$field['editor'] || !in_array($field['group'], ['listing', 'lifecycle'], true)) {
continue;
}
if (($category_slugs || $values) && !FieldSchemas::field_applies($route, $key, $category_slugs, $values)) {
delete_post_meta($post_id, $key);
// Keep subsequent dependent fields from seeing a value whose
// parent field was just removed as invalid or inapplicable.
$values[$key] = '';
continue;
}
if (!array_key_exists($key, $_POST)) {
// A partial editor save must validate the persisted value as
// well. Older editor surfaces and imported posts may not
// submit every schema field, but an existing value remains a
// valid answer to a category-dependent requirement.
continue;
}
self::save_field($post_id, $key, $values[$key] ?? '');
}
$missing = FieldSchemas::required_fields_for_categories($route, $category_slugs, $values);
if ($missing) {
self::$pending_required_fields = array_values(array_unique(array_merge(self::$pending_required_fields, $missing)));
}
}
public static function redirect_validation_notice(string $location): string
{
if (!self::$pending_required_fields) {
return $location;
}
return add_query_arg(
'mw_required_listing_fields',
implode(',', array_map('sanitize_key', self::$pending_required_fields)),
$location
);
}
public static function render_validation_notice(): void
{
$raw = isset($_GET['mw_required_listing_fields']) ? sanitize_text_field(wp_unslash($_GET['mw_required_listing_fields'])) : '';
$keys = array_values(array_filter(array_map('sanitize_key', explode(',', $raw))));
if (!$keys) {
return;
}
$labels = array_map(static fn (string $key): string => ucwords(str_replace('_', ' ', preg_replace('/^mw_/', '', $key))), $keys);
echo '<div class="notice notice-error is-dismissible"><p>' . esc_html__('Bitte ergänze die kategorieabhängigen Pflichtfelder:', 'meinweimar-core') . ' ' . esc_html(implode(', ', $labels)) . '</p></div>';
}
private static function save_gallery(int $post_id): void
{
$ids = ContentTypes::sanitize_gallery_ids(wp_unslash($_POST['mw_gallery_ids'] ?? ''));
if (!$ids) {
delete_post_meta($post_id, 'mw_gallery_ids');
return;
}
update_post_meta($post_id, 'mw_gallery_ids', $ids);
}
private static function can_save(int $post_id, WP_Post $post): bool
{
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return false;
}
if (wp_is_post_revision($post_id) || !in_array($post->post_type, array_values(ContentTypes::public_post_types()), true)) {
return false;
}
if (!isset($_POST[self::NONCE])) {
if (!defined('REST_REQUEST') || !REST_REQUEST) {
return false;
}
} elseif (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST[self::NONCE])), self::NONCE)) {
return false;
}
return current_user_can('edit_post', $post_id);
}
private static function coordinate($value, float $minimum, float $maximum): string
{
if ('' === (string) $value || !is_numeric($value)) {
return '';
}
$value = (float) $value;
return ($value >= $minimum && $value <= $maximum) ? (string) $value : '';
}
private static function allowed_value($value, array $allowed): string
{
$value = sanitize_text_field(wp_unslash((string) $value));
return in_array($value, $allowed, true) ? $value : $allowed[0];
}
private static function save_field(int $post_id, string $key, $value): void
{
if ('' === $value || 0 === $value) {
delete_post_meta($post_id, $key);
return;
}
update_post_meta($post_id, $key, $value);
}
}

Architecture

High-level modules detected from the verified public snapshot. Protected implementation details are intentionally omitted.

flutter-appmodule
wordpress-pluginmodule

Technology stack

DartPHPJavaScriptARBLOCKPBXPROJFlutter / Dart
Dart56.1%
PHP29.4%
JavaScript4.0%
ARB2.9%
LOCK1.7%
PBXPROJ1.7%
CSS1.5%
Markdown0.4%