diff --git a/AGENTS.md b/AGENTS.md index 36ae1a22..08ac6b81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,8 +209,15 @@ setting has one home, and a second copy is a second answer waiting to disagree w **Colours that are legitimately literal**: scrims and hairlines drawn over listing photography, which stays photography in both themes; `#000` used as a mask stencil; white on the accent, which -is dark red either way. The map basemap is the light OpenFreeMap style in both themes, so map -overlays follow the page rather than inverting. +is dark red either way; and MapLibre paint and marker colours (`ui/src/components/map/overlayLayers.js`, +`darkBasemapPaint.js`, `markerColors.js`), because a map layer or marker takes a colour string and +cannot read a custom property. Anything drawn in HTML around the map (legend, badges) still uses +the tokens. + +**The map follows the theme.** The vector basemap is OpenFreeMap's `bright` style in the light theme +and its `dark` style in the dark theme; satellite imagery is the same in both (`isDarkBasemap` in +`ui/src/components/map/Map.jsx`). The overlays carry one paint set per basemap (`OVERLAY_PAINT.light` +and `.dark`), and the canvas is dimmed on a bright basemap and lifted on the dark one. **Tracking.** Switching theme fires `CHANGE_THEME_DARK` or `CHANGE_THEME_LIGHT`. A tracking event carries a feature name and nothing else (`trackPoi` sends one string), so any value worth reporting diff --git a/README.md b/README.md index 1f29b1fb..afa4214d 100755 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ # Fredy 🏡 - Your Self-Hosted Real Estate Finder for Europe -**Fredy** watches **24 real estate portals** across 🇩🇪 🇦🇹 🇨🇭 🇪🇸 🇮🇹 🇵🇹 for you (e.g. Immoscout, +**Fredy** watches **26 real estate portals** across 🇩🇪 🇦🇹 🇨🇭 🇪🇸 🇮🇹 🇵🇹 for you (e.g. Immoscout, Kleinanzeigen etc), drops duplicates across platforms, and notifies you via **Slack, Telegram, Email, ntfy, Discord and more** the moment a new listing appears. Searches are managed from a Web UI, and you never see the same listing twice. @@ -96,8 +96,9 @@ Fredy is in the [Unraid](https://unraid.net/) community store. ## ✨ What you get -- 🏠 **24 portals** across 🇩🇪 🇦🇹 🇨🇭 🇪🇸 🇮🇹 🇵🇹: ImmoScout24, Immowelt, Kleinanzeigen, WG-Gesucht, - willhaben, Flatfox, idealista, Subito and [16 more](doc/providers.md) +- 🏠 **26 portals** across 🇩🇪 🇦🇹 🇨🇭 🇪🇸 🇮🇹 🇵🇹: ImmoScout24 (Germany and Austria), Immowelt, + Kleinanzeigen, WG-Gesucht, willhaben, Flatfox, idealista, Subito and + [18 more](doc/providers.md) - ⚡ **Instant notifications**: Slack, Telegram, Email (SMTP, SendGrid, Mailjet, Resend), ntfy, Discord, Mattermost, Pushover, Apprise and more - 🔄 **Deduplication across platforms**: the same flat advertised on ImmoScout, Immowelt and @@ -234,7 +235,7 @@ class node_debug,node_mcp toneMint | Topic | What is in there | |---|---| -| [Providers & scraping](doc/providers.md) | All 24 providers, the Immoscout / idealista / Casa.it specifics, and residential proxies for when a VPS gets blocked | +| [Providers & scraping](doc/providers.md) | All 26 providers, the Immoscout / idealista / Casa.it specifics, and residential proxies for when a VPS gets blocked | | [Scam detection](doc/scam-detection.md) | The signals, their weights, the languages, and how to overrule Fredy | | [Financing calculator](doc/financing.md) | Rent and Annuitätendarlehen, Kaufnebenkosten, Restschuld, the 35 % rule | | [Travel time & public transport](doc/travel-time.md) | Addresses and place types, estimated vs exact, route drawing, departure boards, operator settings | diff --git a/doc/providers.md b/doc/providers.md index 899fd5e6..3adf57fb 100644 --- a/doc/providers.md +++ b/doc/providers.md @@ -7,7 +7,7 @@ platform into Fredy. > Always make sure the search results are sorted by **date**, so Fredy picks up the newest listings > first. -## The 24 built-in providers +## The 26 built-in providers **🇩🇪 Germany** @@ -20,7 +20,8 @@ platform into Fredy. | InBerlinWohnen | Kleinanzeigen | Sparkasse Immobilien | | McMakler | Wg gesucht | | -**🇦🇹 Austria** · willhaben +**🇩🇪 Germany · 🇦🇹 Austria · 🇨🇭 Switzerland** · BETTERHOMES +**🇦🇹 Austria** · willhaben · Immoscout Österreich **🇨🇭 Switzerland** · Flatfox **🇪🇸 Spain · 🇮🇹 Italy · 🇵🇹 Portugal** · idealista **🇮🇹 Italy** · Subito · Tecnocasa · Tecnorete · Casa.it @@ -51,6 +52,39 @@ Worth knowing: - If a search URL cannot be mapped at all, the job fails with `Real estate type not found: `. Please open an issue with the URL, it is a one line fix. +## Immoscout Österreich + +`immobilienscout24.at` is a separate provider (`immoscoutAt`), not a country setting on the German +one. The two websites share nothing you can see: Austria has its own URL scheme +(`/regional///`), its own filter parameters and its own listing ids. +What they do share is the index behind them, so Fredy reads Austria through the same reverse +engineered mobile API and both providers are thin descriptors over one client. + +Paste the search URL from `immobilienscout24.at` as usual. Flats, houses and plots are covered, with +the site's filters for price, living space and room count, whether they sit in the path +(`wohnung-bis-1100-euro-mieten`) or in the query string (`?primaryPriceTo=1100`). Paging and sorting +in the path (`/seite-3`, `/aktualitaet`) are ignored rather than refused - Fredy walks and sorts on +its own. + +Three limits, all of them the API's rather than Fredy's: + +- **Districts are widened.** The Austrian part of the index files areas by Bundesland and Gemeinde + and no deeper, so a Viennese district URL searches all of Vienna and says so in the log. Narrow + the job down with a map area filter if that is too wide. +- **Renting and buying cannot be searched at once.** The API answers a request for both with the + first of the two and reports nothing about it, so the site's plural pages (`wohnungen`, + `einfamilienhaeuser`, `3-zimmer-wohnungen`, ...) are refused with the single-deal alternative + named in the message. `immobilien`, which spans four types, does work. +- **Commercial searches are not supported**, the same as on the German site. + +Listings link to `immobilienscout24.de/expose/`. That is not a mistake: an Austrian advert is +served by the German index under a German id, and the API states that page as the advert's own +share link. + +**Switzerland is not covered by this provider.** `immoscout24.ch` belongs to a different company and +runs a different platform, with no listings in this index at all. Switzerland is served by Flatfox +and BETTERHOMES. + ## idealista Uses the mobile APIs for idealista.com, idealista.it and idealista.pt, and the search URL determines @@ -70,6 +104,21 @@ translated into API requests use the job's browser; the fallback reads up to twe when a page provides no valid results. See the [provider documentation](../reverse-engineered-casa.md) for supported endpoints and filters. +## BETTERHOMES + +One brokerage on three domains - `betterhomes.de`, `betterhomes.at` and `betterhomes.ch` - and a +search url from any of them works. Its results page fills itself from a JSON endpoint, which is +what Fredy asks as well, so this provider needs no browser and costs one request per run. + +Paste the search url as usual: every filter it carries is passed on untouched, so anything the +portal offers works whether or not Fredy has heard of it. The exact street is never published on a +BETTERHOMES advert, so a listing is located by its postcode and district. The coordinates the +portal itself shows are its town's centre, so they are only used for an advert without any address. + +A rent is stored as the Nettomiete, like every other provider's, because the affordability check +adds the Nebenkosten itself; the Bruttomiete is only the fallback for an advert that states no net +figure. + ## Countries and the map **Every provider declares the countries it covers**, and the job form puts the matching flag in diff --git a/docs/plans/immoscout-architecture.md b/docs/plans/immoscout-architecture.md new file mode 100644 index 00000000..890fcb22 --- /dev/null +++ b/docs/plans/immoscout-architecture.md @@ -0,0 +1,142 @@ +# ImmoScout: leichter bauen, ohne Features zu verlieren + +Stand nach dem Österreich-Umbau. Erster Teil beschreibt, was jetzt da ist und warum. Zweiter Teil +sind Vorschläge, die noch nicht umgesetzt sind, mit Aufwand und Risiko. + +## Wo das Gewicht liegt + +Gemessen, nicht geschätzt (`wc -l`): + +| Datei | LOC | Art | +|---|---:|---| +| `lib/services/immoscout/mobileApi.js` | 526 | Engine: Requests, Parser, Probes, Provider-Factory | +| `lib/services/immoscout/at-paths.js` | 452 | AT-Tabellen | +| `lib/services/immoscout/web-paths.js` | 253 | DE-Tabellen | +| `lib/services/immoscout/immoscout-web-translator.js` | 249 | URL-Zusammenbau, beide Länder | +| `lib/services/immoscout/param-support.js` | 201 | Parameter × Typ Matrix | +| `lib/services/immoscout/shape.js` | 82 | Polyline-Dekodierung | +| `lib/services/immoscout/real-estate-types.js` | 62 | Vokabular | +| `lib/provider/immoscoutAt.js` | 38 | Deskriptor | +| `lib/provider/immoscout.js` | 26 | Deskriptor | +| **Summe** | **1889** | | + +Vorher (nur Deutschland): 1190 LOC, davon 457 im Provider selbst. + +Österreich hat **604 Zeilen** gekostet (`at-paths.js` + `immoscoutAt.js` + 114 Zeilen Translator). +Ein zweiter Provider per Copy-Paste hätte rund 900 gekostet **und** eine zweite Kopie des +526-Zeilen-Clients erzeugt, die bei jedem API-Wechsel mitgezogen werden müsste. + +Entscheidend für alles Weitere: **rund 900 der 1889 Zeilen sind handgepflegte Tabellen** +(`at-paths`, `web-paths`, `param-support`). Das ist der Teil, der verrottet, wenn ImmoScout eine +Seite ändert, und der Teil, den niemand vollständig bekommt. + +## Was jetzt steht: Deskriptor über Engine + +Ein nationales Portal steuert genau zwei Dinge bei: seine Identität und einen Leser für seine +URL-Form. Alles andere ist identisch, weil hinter beiden Sites **eine** API auf **einem** Index +liegt. + +``` +lib/provider/immoscout.js ─┐ + ├─→ buildImmoscoutProvider(portal) ─→ mobileApi.js (Engine) +lib/provider/immoscoutAt.js ┘ ↑ + └── portal.toMobileSearchUrl ─→ web-paths.js | at-paths.js +``` + +Zwei Entwurfsentscheidungen, die nicht offensichtlich sind: + +- **`baseUrl` ≠ wo das Inserat liegt.** Das AT-Portal ist `immobilienscout24.at`, aber ein + österreichisches Inserat wird vom deutschen Index unter einer deutschen numerischen ID + ausgeliefert, und die API nennt selbst die `.de`-Seite als Share-Link. Deshalb gibt es + `EXPOSE_BASE_URL` getrennt von `portal.baseUrl`. Ein aus `baseUrl` gebauter Link wäre 404. +- **Zwei Provider statt `countries: ['de','at']`.** Weil die API einen gemeinsamen ID-Raum + zurückgibt, könnte ein `countryOf(listing)` die beiden nie auseinanderhalten. Ein Provider pro + Site deklariert ein Land, und die Frage stellt sich nicht. Geocoder und Karte bekommen eine + Antwort, die stimmt. + +## Vorschlag 1: `param-support.js` durch die API selbst ersetzen + +**Das größte Einzelstück, das weg kann.** 201 Zeilen Matrix, aufgenommen durch Replay. + +Die API sagt bei jeder Ablehnung, **welchen** Parameter sie meint. Diese Session verifiziert, alle +drei Fälle: + +``` +haspromotion auf housebuy → ERROR_COMMON_URL_PARAMETER_NOT_SUPPORTED + "The parameter [haspromotion] is not supported." +pricetype=calculatedtotalrent + auf houserent → ERROR_COMMON_URL_PARAMETER_VALIDATION_FAILED + "The parameter [pricetype] has an invalid value [calculatedtotalrent]." +unbekannter Parameter → ERROR_COMMON_URL_PARAMETER_NOT_SUPPORTED + "The parameter [fredynonsense] is not supported." +``` + +Statt die Matrix zu pflegen: **schicken, Ablehnung lesen, genannten Parameter streichen, erneut +schicken.** Ergebnis pro `(realestatetype, parameter)` in einem Cache lernen, damit es einmal pro +Instanz kostet und nicht pro Lauf. + +Das ist nicht nur kürzer, es ist **feature-completer**: heute wird ein Filter, den niemand +aufgenommen hat, mit `no translator` verworfen und die Suche läuft breiter als gesetzt. Mit dem +Reader funktioniert er, sobald die API ihn akzeptiert. + +- **Gewinn:** ~160 der 201 Zeilen weg. Übrig bleiben die Noise-Liste und der 412-Leser. +- **Kosten:** beim ersten Lauf bis zu N Zusatz-Requests, N = Zahl der abgelehnten Parameter. Die + API nennt pro 412 nur *einen*, also iterativ. Mit Cache konvergiert das nach einem Lauf. +- **Risiko:** ein 412 aus anderem Grund darf nicht als "Parameter nicht unterstützt" gelernt + werden. Absichern, indem nur auf die zwei bekannten `messageCode` gelernt wird und die + Iteration hart gedeckelt ist. + +## Vorschlag 2: Pfadtabellen nicht mehr pflegen, sondern die Seite fragen + +`web-paths.js` sagt im eigenen Kopfkommentar, wie die Tabelle entstanden ist: jede Suchseite +berichtet die API-URL, zu der sie aufgelöst hat, in einem Feld `lastSearchApiUrl`. + +Wenn das noch stimmt, ist die 253-Zeilen-Tabelle ersetzbar: beim **Speichern** eines Jobs die +Suchseite einmal laden, `lastSearchApiUrl` lesen, die Mobile-URL am Job ablegen. Tabelle bleibt +als Fallback, darf aber verrotten, ohne dass jemand etwas merkt. + +- **Gewinn:** jeder ImmoScout-Filter funktioniert ab Tag eins, auch die, die keine Tabelle kennt. + `Real estate type not found` verschwindet als Fehlerklasse. +- **Kosten:** ein Request pro Job-Speicherung, nicht pro Lauf. +- **Risiko, und zwar ein echtes:** die DE-Seite antwortet auf einfache Requests mit 401 (in dieser + Session gemessen, für `/Suche/...` genauso wie für `/expose/...`). Das bräuchte also den + Browser- oder Proxy-Pfad, den das Repo für andere Provider schon hat, und würde ImmoScout die + Eigenschaft nehmen, der einzige vollständig browserfreie Provider zu sein. **Ich konnte + `lastSearchApiUrl` in dieser Session nicht nachprüfen**, genau wegen dieser 401. Vor einer + Umsetzung gehört das verifiziert, sonst ist der ganze Vorschlag Spekulation auf einem + Kommentar. +- Für Österreich gilt er ohnehin nicht: die AT-Seite ist eine andere Anwendung und hat das Feld + nicht. + +## Vorschlag 3: eine Slug-Tabelle mit Länder-Overlays statt zweier Vokabulare + +`web-paths.js` und `at-paths.js` beschreiben dieselbe API mit zwei getrennten Tabellen. Rund zehn +Slugs sind identisch (`wohnung-mieten`, `haus-kaufen`, …) und stehen doppelt. + +Eine gemeinsame Basis `slug → (realType, params)` plus je ein Overlay pro Site würde die Dopplung +entfernen und, wichtiger, sichtbar machen, welche Site was kann. + +- **Gewinn:** klein, geschätzt 40–60 Zeilen, plus deutlich bessere Lesbarkeit. +- **Kosten:** ein Umbau an zwei getesteten Tabellen ohne funktionalen Nutzen. +- **Einschätzung:** erst machen, wenn eine dritte Site dazukommt. Bei zwei ist die Trennung noch + ehrlicher als die Abstraktion, weil die beiden Sites tatsächlich verschiedene Vokabulare haben. + +## Was nicht geht + +**Schweiz passt nicht in diese Architektur.** `immoscout24.ch` gehört einer anderen Firma +(SMG Swiss Marketplace Group), läuft auf einer anderen Plattform hinter Cloudflare und DataDome, +und hat **null** Inserate in diesem Index: `/ch`, `/ch/zuerich` und `/ch/bern` antworten mit +`totalResults: 0`, `/ch/zurich/zurich` und `/ch/geneve` mit 412. `api.immoscout24.ch` existiert, +antwortet aber auf jedem Pfad mit 403. + +Ein CH-Provider wäre ein eigenes Reverse-Engineering-Projekt und teilt sich mit diesem Konstrukt +nichts außer dem Namen. Die Schweiz ist bereits über `flatfox` (`['ch']`) und `betterhomes` +(`['de','at','ch']`) abgedeckt. + +## Reihenfolge, wenn umgesetzt wird + +1. **Vorschlag 1** zuerst. Größter Gewinn, geringstes Risiko, keine neue Abhängigkeit, und macht + das Produkt nebenbei vollständiger statt nur kleiner. +2. **Vorschlag 2** nur nach Verifikation von `lastSearchApiUrl` über den Browser-Pfad. Wenn das + Feld weg ist, fällt der Vorschlag ersatzlos. +3. **Vorschlag 3** zurückstellen bis zu einer dritten Site. diff --git a/docs/plans/job-form-redesign.md b/docs/plans/job-form-redesign.md new file mode 100644 index 00000000..14cde8c4 --- /dev/null +++ b/docs/plans/job-form-redesign.md @@ -0,0 +1,2129 @@ +# Job-Formular und seine drei Dialoge + +Umbau von `ui/src/views/jobs/mutation/` samt `ProviderTable`, `ProviderMutator`, +`NotificationChannelPicker` und `NotificationChannelEditor`. + +Der Ledger steht in `job-form-redesign.progress.md`. Abschnitt 0 ist der Arbeitsvertrag, +Abschnitt 3 der vollständige Befund mit Belegen, Abschnitt 4 sind die fünfzehn Schritte. + +--- + +## 0 Arbeitsvertrag + +Diese zehn Regeln gelten für jeden Schritt. Sie sind nicht verhandelbar und sie sind der Grund, +warum dieser Plan so lang ist. + +1. **Ein Schritt, ein Commit, ein Häkchen.** Nach jedem Schritt wird `job-form-redesign.progress.md` + aktualisiert, bevor irgendetwas anderes passiert. Wer zwei Schritte bündelt, macht den Ledger + wertlos. +2. **Keine Farbe, kein Abstand, keine Schriftgröße im Markup.** Kein `style={{ color: … }}`, + kein `style={{ marginBottom: … }}`, kein `style={{ fontSize: … }}`. Breiten und Höhen, die eine + Komponente von außen bekommen muss (`Modal` `style={{ width }}`), bleiben erlaubt. +3. **Farbliterale ausschließlich in `ui/src/themes.less`.** Jede andere Datei benutzt + `@color-*` aus `tokens.less`. `test/ui/theme.test.js` prüft, dass jedes Token in beiden + Theme-Blöcken steht. +4. **Semis Palette ist verboten.** `var(--semi-color-*)` darf in keiner Datei stehen, die dieser + Plan anfasst. Sie ist unsichtbar für `theme.test.js` und folgt nicht Fredys Themen. +5. **Jeder Abstand aus der Skala.** `@space-1` bis `@space-12`. Keine `rem`, keine `px` für + `gap`, `margin`, `padding`. Ausnahmen: Prozentwerte, `100%`, `0`, `auto`, `1px` für Rahmen, + `max-height` in `vh`, und die drei Pixelmaße, die Schritt 13 ausdrücklich erlaubt. +6. **Jede Schriftgröße aus der Skala.** `@text-xs` 11, `@text-sm` 12, `@text-base` 14, + `@text-md` 16, `@text-lg` 20, `@text-xl` 24. `13px` gibt es nicht. +7. **Eine Primäraktion je Fläche.** Genau ein gefüllter Akzentknopf pro Karte, pro Dialog, pro + Fußzeile. Alles andere ist `tertiary` oder `borderless`. `type="secondary"` ist Semis Blau und + kommt in Fredy nicht vor. +8. **Kein `float` für Layout.** Flex oder Grid. +9. **Kein neuer globaler Selektor.** Eine Regel, die mit `.semi-` beginnt, steht entweder unter + einer eigenen Klasse oder sie wird über `dropdownClassName` bzw. `className` an genau die + Instanz gebunden, die sie meint. +10. **Kommentare, die nicht mehr stimmen, werden korrigiert, nicht gelöscht.** Die Begründung ist + der Wert; falsche Zahlen darin sind ein Fehler wie jeder andere. + +--- + +## 1 Was schon dasteht + +Aus dem Einstellungs- und dem Admin-Umbau existieren diese Bausteine bereits. Sie werden benutzt, +nicht neu gebaut. + +| Datei | Was sie kann | +|---|---| +| `ui/src/components/settingsShell/SettingsEmptyState.jsx` | `{ icon, title, description, action }`. Symbol, Titel, ein Satz, ein Knopf. | +| `ui/src/components/settingsShell/SettingsEmptyState.less` | Zentriert, `@space-6 @space-4`, `@color-elevated`-Kreis. | +| `ui/src/components/segment/SegmentPart.jsx` | Prop `action` landet als `headerExtraContent` rechts im Kartenkopf. `helpMode="popover"`. | +| `ui/src/components/table/NotificationChannelTable.jsx` | Prop `emptyText`, Prop `actions` mit `add`, `test`, `edit`, `clone`, `detach`. | +| `ui/src/services/jobs/jobValidation.js` | `JOB_REQUIREMENTS`, `missingRequirements`, `canSaveJob`. | +| `ui/src/components/notificationAdapter/…/NotificationHelpDisplay.less` | Bereits vollständig auf Tokens. Wird nicht angefasst. | + +Prüfe vor Schritt 4, dass `SettingsEmptyState.jsx` existiert. Falls nicht, wurde der +Einstellungs-Plan nie ausgeführt: dann zuerst diese Komponente aus jenem Plan anlegen. + +--- + +## 2 Tokens + +**In diesem Plan wird kein einziges neues Token angelegt.** Alles, was gebraucht wird, steht +bereits in `tokens.less`: `@color-warning` für die Bereitschaftsleiste, `@color-success` für ihren +Fertig-Zustand, `@color-error` für Feldfehler, `@color-accent-dim` für die Filterleiste. + +`@color-faint` wird in diesem Plan nirgends neu gesetzt. Es liegt bei 2,47:1 auf `@color-base` +(dunkel) und 2,88:1 (hell) und besteht AA nicht. Wo Text heute `@color-faint` trägt und dieser +Plan die Zeile ohnehin anfasst, wird `@color-muted` daraus (5,43:1 dunkel, 6,12:1 hell). + +--- + +## 3 Befund + +Alles hier ist nachgelesen, nicht vermutet. Zeilennummern beziehen sich auf den Stand vor +Schritt 1. + +### 3.1 Zwei Dateien beschreiben eine Lösung, die nie gebaut wurde + +`ui/src/services/jobs/jobValidation.js`, Zeilen 9 bis 11: + +``` + * A list of named rules rather than one boolean expression: each rule stands on its own, is tested + * on its own, and reads in the order the sections appear in the form. The form itself only asks + * whether the list is empty - the Save button is disabled until it is, and says nothing about why. +``` + +`ui/src/views/jobs/mutation/JobMutation.jsx`, Zeilen 234 bis 236: + +```js +// A list, not a boolean. A disabled Save with nothing explaining it leaves the user hunting +// through eight sections for whichever one is incomplete. +const missing = missingRequirements({ name, dealType, providerData, selectedChannels }); +``` + +Und die einzige Verwendung von `missing`, Zeile 614: + +```jsx + + ); + })} + + )} + + ); +} + +JobReadinessBar.displayName = 'JobReadinessBar'; +``` + +#### 2.3 `ui/src/views/jobs/mutation/JobReadinessBar.less` (neu) + +```less +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +@import '../../../tokens.less'; + +/* Directly above the two footer buttons, because that is where the question "why can I not save" + is asked. Warning rather than error: nothing is wrong, something is not finished yet. */ +.jobReadiness { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: @space-2 @space-3; + margin-bottom: @space-4; + padding: @space-3 @space-4; + border: 1px solid @color-warning; + border-radius: @radius-card; + background: @color-fill-subtle; + + &__icon { + display: inline-flex; + flex: none; + color: @color-warning; + } + + &__label { + font-size: @text-base; + font-weight: 600; + color: @color-text; + } + + &__items { + display: flex; + flex-wrap: wrap; + gap: @space-2; + } + + /* A real button: it moves the page, so it is a control and gets the keyboard and the focus ring + that go with one. */ + &__item { + padding: @space-1 @space-3; + border: 1px solid @color-border-bright; + border-radius: @radius-pill; + background: @color-elevated; + color: @color-text; + font-family: @font-ui; + font-size: @text-sm; + font-weight: 500; + cursor: pointer; + transition: border-color @transition-fast; + + &:hover { + border-color: @color-warning; + } + + &:focus-visible { + outline: 2px solid @color-accent; + outline-offset: 2px; + } + } + + /* Nothing left to do: the bar stops being a list and becomes one line of confirmation, and Save + next to it is the only filled control on the page. */ + &--ready { + border-color: @color-success; + background: @color-success-dim; + } + + &--ready &__icon { + color: @color-success; + } + + @media (prefers-reduced-motion: reduce) { + &__item { + transition: none; + } + } +} +``` + +#### 2.4 Anker und Hervorhebung in `JobMutation.less` + +Ans Ende der Datei, hinter `.jobMutation__footerActions`: + +```less +/* The four sections the readiness bar can jump to. `scroll-margin-top` keeps the card clear of the + sticky header instead of landing under it. */ +.jobMutation__anchor { + scroll-margin-top: @space-12; +} + +/* Two seconds of outline after a jump. Without it the page moves and nothing says which of the six + cards now on screen was the one meant. */ +.jobMutation__anchor--highlight .semi-card { + outline: 2px solid @color-warning; + outline-offset: 2px; + border-radius: @radius-card; +} +``` + +#### 2.5 `JobMutation.jsx` + +Import ergänzen: + +```js +import JobReadinessBar from './JobReadinessBar.jsx'; +import { SECTION_BY_REQUIREMENT } from './jobSections.js'; +``` + +Zustand, direkt hinter `const [refineOpen, setRefineOpen] = useState(false);`: + +```js +/** Which section the readiness bar last jumped to, so it can be marked for a moment. */ +const [highlighted, setHighlighted] = useState(null); + +useEffect(() => { + if (highlighted == null) return undefined; + const timer = setTimeout(() => setHighlighted(null), 2000); + return () => clearTimeout(timer); +}, [highlighted]); +``` + +Der Kommentar über `missing` wird korrigiert (Regel 10). Alt: + +```js +// A list, not a boolean. A disabled Save with nothing explaining it leaves the user hunting +// through eight sections for whichever one is incomplete. +``` + +Neu: + +```js +// A list, not a boolean. It is rendered as one by the readiness bar above the footer: a disabled +// Save with nothing explaining it left the user hunting through ten sections, seven of them +// visible without a click, for whichever one was incomplete. +``` + +Eine kleine Hilfe, direkt darunter: + +```js +/** + * The wrapper that makes a section a jump target for the readiness bar. + * + * @param {string} requirementKey + * @returns {{ id: string, className: string }} + */ +const anchorProps = (requirementKey) => { + const id = SECTION_BY_REQUIREMENT[requirementKey]; + return { + id, + className: `jobMutation__anchor${highlighted === id ? ' jobMutation__anchor--highlight' : ''}`, + }; +}; +``` + +Die vier Pflicht-Abschnitte werden in genau dieses `div` gewickelt. Beispiel für „Name“: + +```jsx +
+ + …unverändert… + +
+``` + +Genauso für `'provider'` (Anbieter), `'dealType'` (Mieten oder Kaufen) und `'channel'` +(Benachrichtigungskanäle). Die übrigen Abschnitte bekommen kein `div`. + +Die Fußzeile: + +```jsx +{/* Sticky it no longer is - see the stylesheet. What is new here is the bar above the buttons: + the same list the Save button already consults, said out loud. */} +
+ +
+ {/* Cancel used to be `danger`, so the red button was the harmless one and Save sat next + to it in the colour that usually means "go ahead". */} + + +
+
+``` + +Der gesperrte Knopf bleibt gesperrt. Er erklärt sich jetzt nur. + +--- + +### Schritt 3 — Anbieter: Leerzustand und Aktion im Kartenkopf + +In `JobMutation.jsx` wird der Anbieter-Abschnitt zu: + +```jsx +
+ } + onClick={() => { + setProviderToEdit(null); + setProviderCreationVisibility(true); + }} + > + {t('jobs.mutation.addProvider')} + + ) + } + > + {providerData.length === 0 ? ( + } + title={t('jobs.mutation.providerEmptyTitle')} + description={t('jobs.mutation.providerEmptyText')} + action={ + + } + /> + ) : ( + { + setProviderData(providerData.filter((provider) => provider.url !== providerUrl)); + }} + onEdit={(provider) => { + setProviderCreationVisibility(true); + setProviderToEdit(provider); + }} + /> + )} + +
+``` + +Import ergänzen: + +```js +import SettingsEmptyState from '../../../components/settingsShell/SettingsEmptyState'; +``` + +Der Knopf mit `className="jobMutation__newButton"` verschwindet aus diesem Abschnitt ersatzlos: +im leeren Fall steht er im Leerzustand, im gefüllten Fall im Kartenkopf. + +--- + +### Schritt 4 — Kanäle: Leerzustand, eine Knopffarbe + +```jsx +
+ } onClick={() => setPickerVisible(true)}> + {t('jobs.mutation.addNotification')} + + ) + } + > + {selectedChannels.length === 0 ? ( + } + title={t('jobs.mutation.channelEmptyTitle')} + description={t('jobs.mutation.channelEmptyText')} + action={ + + } + /> + ) : ( + + )} + +
+``` + +Der Knopf „Kanäle verwalten“ (`type="secondary"`, Semis Blau) verschwindet als Knopf. Er war schon +immer eine Navigation, kein Kommando. Er wird ein Link unter der Tabelle: + +```jsx +{selectedChannels.length > 0 && ( + +)} +``` + +direkt hinter der Tabelle, noch innerhalb des `SegmentPart`. + +`.jobMutation__notificationActions` wird nicht mehr gerendert. In `JobMutation.less`: + +```less +/* Navigation away from the form, not a command inside it: borderless, under the list it belongs + to. It used to be a filled blue button beside a filled accent one - two colours, two weights, + for "add one" and "go somewhere else". */ +.jobMutation__manageLink { + margin-top: @space-2; +} +``` + +--- + +### Schritt 5 — Veröffentlichung: zwei Karten werden eine + +„Mit Benutzer teilen“ und „Job-Aktivierung“ sind beides Entscheidungen über den Job selbst, keine +Filter. Sie stehen schon heute nebeneinander außerhalb des Folds. Sie werden eine Karte. + +```jsx +{/* Outside the fold, and after it: neither is a filter. Who else sees this job and whether it runs + at all are decisions about the job itself, and burying them under a heading that says "filters" + is how people missed the switch that turns the job on. One card rather than two, because they + are one question asked twice: what happens with this job once it exists. */} + +
+
+ {t('jobs.mutation.sectionSharing')} +
+ {shareableUserList.length === 0 ? ( + {t('jobs.mutation.sharingNoUsers')} + ) : ( + + )} +
+
+ +
+ {t('jobs.mutation.sectionActivation')} +
+ setEnabled(checked)} checked={enabled} /> +
+
+
+
+``` + +`IconPlayCircle` wird damit nicht mehr importiert und fliegt aus der Importliste. +`jobMutation__spaceTop` am `Switch` fällt weg. + +In `JobMutation.less`: + +```less +/* Two decisions about the job itself, as a label column and a control column. A definition list + would be the semantic match, but the control side holds a multi-select and a switch, and a `dd` + full of widgets buys nothing a labelled row does not. */ +.jobMutation__publication { + display: flex; + flex-direction: column; + gap: @space-3; +} + +.jobMutation__publicationRow { + display: flex; + align-items: center; + gap: @space-4; + + & + & { + padding-top: @space-3; + border-top: 1px solid @color-border; + } +} + +.jobMutation__publicationLabel { + flex: none; + width: 11rem; + font-size: @text-sm; + font-weight: 500; + color: @color-muted; +} + +.jobMutation__publicationControl { + flex: 1; + min-width: 0; +} + +.jobMutation__publicationNote { + font-size: @text-sm; + color: @color-muted; +} + +/* The two selects of this form that must fill their row. Replaces `style={{ width: '100%' }}`. */ +.jobMutation__fullWidth { + width: 100%; +} + +@media (max-width: 700px) { + .jobMutation__publicationRow { + align-items: flex-start; + flex-direction: column; + gap: @space-2; + } + + .jobMutation__publicationLabel { + width: auto; + } +} +``` + +`11rem` ist eine Spaltenbreite, kein Abstand, und damit von Regel 5 nicht betroffen. + +--- + +### Schritt 6 — Der Kommentar über dem Fold, und der Zurück-Knopf + +Kommentar über dem ersten `SegmentPart`, alt: + +```jsx +{/* The three things a job cannot exist without, and nothing else. Everything optional is + folded away below, so the shortest path to a working job is a straight read down this + column rather than a scroll past nine open cards. */} +``` + +neu: + +```jsx +{/* The four things a job cannot exist without, in the order `JOB_REQUIREMENTS` names them, and + the two decisions about the job itself. Every filter is folded away below, so the shortest + path to a working job is a straight read down this column rather than a scroll past nine open + cards. The readiness bar at the foot says which of the four is still open. */} +``` + +Der Zurück-Knopf verliert seine Farbe im Markup: + +```jsx + +``` + +```less +/* Leaves the page, so it is quieter than anything that changes it. */ +.jobMutation__back { + color: @color-muted; +} +``` + +Der Entwurfs-Banner verliert seinen Inline-Abstand: + +```jsx + +``` + +```less +.jobMutation__draftNotice { + margin-bottom: @space-4; +} +``` + +--- + +### Schritt 7 — Die Filterleiste verliert zwei von drei Akzentsignalen + +Heute trägt der Kopf des Folds drei: einen Akzentrahmen, einen Akzent-Hover und eine +Versalpille in Akzentfarbe. Dieselbe Kritik wie am aktiven Eintrag der Navigation: ein Signal +genügt, drei lesen sich als Werbung. + +In `JobMutation.less`: + +```less +.jobMutation__refine { + margin-bottom: @space-4; + + /* Reads as a control rather than a caption: the pointer and the lift on hover are the whole of + what tells someone the filters are one click away. The accent used to do that job three times + over - border, hover border and an uppercase pill - for a section that is optional. */ + .semi-collapse-header { + padding: @space-3 @space-4; + border: 1px solid @color-border; + border-radius: @radius-card; + background: @color-fill-subtle; + cursor: pointer; + transition: + background @transition-fast, + border-color @transition-fast; + + &:hover { + border-color: @color-border-bright; + background: @color-fill-1; + } + } + + …`semi-collapse-content` unverändert… +} +``` + +Und die Pille wird ein Wort: + +```less +/* Which way the next click goes, which a chevron on its own does not say. In the body colour and + at body size: it is a label on a header, not a badge. */ +.jobMutation__refineToggle { + margin-left: auto; + color: @color-muted; + font-size: @text-sm; + white-space: nowrap; +} +``` + +`border`, `border-radius`, `padding`, `text-transform`, `letter-spacing`, `font-weight` und +`color: @color-accent` fallen ersatzlos weg. + +--- + +### Schritt 8 — `ProviderTable`: Aktionen wie überall + +`ui/src/components/table/ProviderTable.jsx`, die dritte Spalte vollständig neu: + +```jsx + { + // A named column, not an empty header. Two icon buttons under a blank heading are two + // symbols nobody has to be able to read. + title: t('provider.tableColumnActions'), + dataIndex: 'tools', + width: 120, + render: (_, record) => ( +
+ +
+ ), + }, +``` + +Import ergänzen: `Tooltip` aus `@douyinfe/semi-ui-19`. + +Neue Datei `ui/src/components/table/ProviderTable.less`: + +```less +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +@import '../../tokens.less'; + +/* Right-aligned by flex, not by `float`, and spaced by a gap rather than by an empty 16px div. */ +.providerTable__actions { + display: flex; + justify-content: flex-end; + gap: @space-2; +} +``` + +und `import './ProviderTable.less';` in der JSX. + +Damit ist die harmlose Aktion nicht länger die auffälligere: beide sind randlos, nur die +gefährliche trägt die Fehlerfarbe. + +--- + +### Schritt 9 — `JobMutation.less` aufräumen + +Vier Dinge, alle belegt in Abschnitt 3. + +**a) Der globale Selektor fällt.** Die drei Zeilen + +```less +.semi-select-option-list-wrapper { + width: 25rem; +} +``` + +werden gelöscht und durch eine gebundene Regel ersetzt: + +```less +/* Bound to the three selects of this form by `dropdownClassName`, because Semi renders the list in + a portal at `document.body`: a descendant selector from inside the form cannot reach it. This + rule used to sit at the top level of this file without a class at all, which made every select + list in the application 25rem wide the moment the job form had been opened once. */ +.jobMutation__dropdown { + min-width: 18rem; +} +``` + +und an den drei `Select` dieser Seite (Mieten/Kaufen, Teilen, und in Schritt 10 der Anbieter) +`dropdownClassName="jobMutation__dropdown"`. + +**b) `float` fällt.** `.jobMutation__newButton` wird gelöscht. Nach den Schritten 3 und 4 hängt die +Klasse an keinem Knopf mehr. + +**c) Die tote Klasse fällt.** `.jobMutation__actions` wird gelöscht. + +**d) Die freien Maße.** `padding-left: 1.25rem` an `.jobMutation__areaSteps` wird `@space-5`. +`height: 26rem` und `50rem` an `.jobMutation__areaMap` bleiben: das sind Kartenhöhen, keine +Abstände, und beide sind in ihrem Kommentar begründet. `0.5rem` und `1rem` an +`.jobMutation__notificationActions` verschwinden mit der Klasse aus Schritt 4. + +Am `Select` für den Typ ersetzt + +```jsx +style={{ width: '100%', maxWidth: 220 }} +``` + +sich durch `className="jobMutation__dealType"` und + +```less +.jobMutation__dealType { + width: 100%; + max-width: 14rem; +} +``` + +--- + +### Schritt 10 — `ProviderMutator`: zwei Schritte statt drei Sätze + +#### 10.1 `ProviderMutator.jsx` + +Der Rumpf des Modals wird zu zwei nummerierten Schritten, jeder mit genau dem Feld, das er +beschreibt. Der Fehler wandert an das Feld, das ihn verursacht hat. + +Zuerst: `validate()` gibt nicht mehr nur den Text zurück, sondern auch, wohin er gehört. + +```js + /** + * Why the pasted URL cannot be used, in words the user can act on, and at which field. + * + * The three URL problems belong under the URL field - they are all statements about the thing + * that was pasted. The fourth case is "nothing chosen at all", which is not about either field + * in particular and stays a notice above both. + * + * @returns {{ where: 'url'|'form', message: string }|null} + */ + const validate = () => { + const { ok, problem, expectedHost } = validateProviderUrl(providerUrl, selectedProvider); + if (ok) { + return null; + } + switch (problem) { + case 'bareHost': + return { where: 'url', message: t('provider.validationBareHost', { host: expectedHost }) }; + case 'wrongHost': + return { where: 'url', message: t('provider.validationWrongHost', { host: expectedHost }) }; + case 'unparsable': + return { where: 'url', message: t('provider.validationUnparsable') }; + default: + return { where: 'form', message: t('provider.validationSelectAndUrl') }; + } + }; +``` + +`validationMessage` hält damit ein Objekt oder `null`. Alle drei `setValidationMessage(null)` +bleiben, wie sie sind. + +Der Rumpf: + +```jsx + onSubmit(true)} + onCancel={() => onSubmit(false)} + // Three short lines and two fields do not need half a screen. It was 50rem, which left the + // controls stranded in the left third of an otherwise empty dialog. + style={{ width: isMobile ? '95%' : '34rem' }} + okText={providerToEdit ? t('provider.save') : t('provider.addAction')} + > + {validationMessage?.where === 'form' && ( + + )} + + {providerToEdit != null ? ( +

+ {t('provider.editDescription', { name: providerToEdit.name })} +

+ ) : null} + + {/* Two numbered steps, each holding the field it describes. They used to be three sentences + of grey prose above three controls that did not correspond to them: step one said + "choose below", step three said "paste it here" and meant a field two rows further + down. */} +
    +
  1. + {t('provider.stepChooseTitle')} + { + setProviderUrl(value); + setValidationMessage(null); + }} + /> + {validationMessage?.where === 'url' && ( + {validationMessage.message} + )} +
  2. +
+
+``` + +`width={10}` am `Input` fällt weg — es war der Grund für das `!important` im Stylesheet. +`style={{ width: '100%' }}` am `Select` fällt weg, die Klasse tut es schon. +Der Inline-Style am `Banner`-Titel fällt weg, samt Titel: die Überschrift „Fehler“ über einem +einzigen Satz war eine Zeile ohne Information. + +#### 10.2 `ProviderMutator.less` + +Vollständig: + +```less +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +@import '../../../../../tokens.less'; + +.providerMutator { + /* Both controls fill the dialog. They were pinned at 25rem inside a 50rem modal, which read as + two small boxes floating in the left half of it. The `!important` this used to need is gone + with the `width={10}` prop that fought it. */ + &__fields { + width: 100%; + } + + &__banner { + margin-bottom: @space-4; + } + + &__editNote { + margin: 0 0 @space-4; + font-size: @text-sm; + line-height: 1.6; + color: @color-muted; + } + + /* A procedure carried out across two browser tabs: the order is the whole of it, so the steps + are numbered and each one holds the control it describes rather than pointing at one further + down the dialog. */ + &__steps { + margin: 0; + padding-left: @space-5; + color: @color-muted; + font-size: @text-sm; + } + + &__step { + display: flex; + flex-direction: column; + + & + & { + margin-top: @space-4; + padding-top: @space-4; + border-top: 1px solid @color-border; + } + } + + &__stepTitle { + margin-bottom: @space-2; + font-size: @text-base; + font-weight: 600; + color: @color-text; + } + + &__stepHint { + font-size: @text-sm; + line-height: 1.6; + color: @color-muted; + } + + /* Reserved whether or not a provider has been chosen, so picking one does not move the field + below it out from under the cursor. */ + &__openRow { + display: flex; + align-items: center; + min-height: @space-6; + } + + &__openLink { + display: inline-flex; + align-items: center; + gap: @space-1; + color: @color-accent; + font-size: @text-sm; + text-decoration: none; + + &:hover, + &:focus-visible { + text-decoration: underline; + } + } + + /* At the field, not in a banner above everything: all three of these are statements about the + address that was pasted. */ + &__error { + margin-top: @space-2; + font-size: @text-sm; + line-height: 1.6; + color: @color-error; + } +} + +/* Semi sizes the option list off the widest option and renders it in a portal at `document.body`. + A descendant selector from inside the modal cannot reach it - which is what the old + `.providerMutator .semi-select-option-list-wrapper` tried and why a global 25rem rule ended up + in JobMutation.less instead. `dropdownClassName` is the binding that works. */ +.providerMutator__dropdown { + max-width: 32rem; +} +``` + +`@color-error` auf `@color-surface` misst 4,51:1 (dunkel) und 6,23:1 (hell). AA für Fließtext +ist damit erfüllt. + +--- + +### Schritt 11 — `NotificationChannelPicker`: Tokens und ein echter Leerzustand + +#### 11.1 `NotificationChannelPicker.jsx` + +Der Leerzustand wird `SettingsEmptyState`. Die beiden Ursachen bleiben unterschieden — das war +schon immer richtig und steht so im Kommentar. + +```jsx + // Nothing to offer has two very different causes, and telling a user they have no channels when + // they have three - all already on this job - sends them off to create a duplicate. + const nothingExists = channels.length === 0; + + const leaveForSettings = () => { + onClose(); + if (onManageChannels != null) { + onManageChannels(); + return; + } + navigate('/settings/notifications'); + }; + + // The same empty state the rest of the app uses: what is missing, what that costs, and the one + // button that fixes it. It used to be a single centred sentence with a link in Semi's blue - + // rendered instead of the table, not inside its empty slot, because Semi lays the table's + // placeholder out at the width of the (empty) table rather than the modal's, which broke one + // sentence into nine stacked fragments a few pixels wide. That reason still holds. + const emptyState = ( + } + title={ + nothingExists + ? t('notification.channels.emptyTitle') + : t('notification.channels.pickerAllAddedTitle') + } + description={ + nothingExists + ? t('notification.channels.emptyText') + : t('notification.channels.pickerAllAddedLead') + } + action={ + + } + /> + ); +``` + +Importe ergänzen: `Button` aus `@douyinfe/semi-ui-19`, `IconBell` und `IconPlusCircle` aus +`@douyinfe/semi-icons`, `SettingsEmptyState` aus `../../../../../components/settingsShell/SettingsEmptyState`. + +Die Modalbreite bekommt eine Klasse statt einer Zahl im Markup? Nein — `Modal` braucht die Breite +als Prop, und sie ist vom Bildschirm abhängig. `46rem` bleibt, wo es steht. Das ist die eine +Ausnahme von Regel 2, und sie ist genau die, die Regel 2 ausdrücklich zulässt. + +#### 11.2 `NotificationChannelPicker.less` + +Vollständig: + +```less +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +@import '../../../../../tokens.less'; + +/* This file used to import nothing and colour with `--semi-color-text-1`, `--semi-color-text-2` + and `--semi-color-primary`. theme.test.js checks that every `--f-` token is defined in both + theme blocks and knows nothing about Semi's variables, so this dialog was outside every theme + check the project has. */ +.channelPicker { + &__intro { + margin: 0 0 @space-3; + font-size: @text-sm; + line-height: 1.5; + color: @color-muted; + } +} +``` + +`__empty` und `__emptyLink` fallen ersatzlos weg: `SettingsEmptyState` bringt sein eigenes +Stylesheet mit. + +--- + +### Schritt 12 — `NotificationChannelEditor`: zwei Gruppen, eine Fußzeile, eine echte Liste + +#### 12.1 Die Fehlerliste + +```js + const [validationProblems, setValidationProblems] = useState([]); +``` + +ersetzt `validationMessage`. `save()`: + +```js + const save = async () => { + const problems = validateChannel(draft, adapterConfig, t); + if (problems.length > 0) { + setValidationProblems(problems); + return; + } + setSaving(true); + try { + const saved = await actions.notificationChannels.saveChannel(toPayload(draft)); + Toast.success(t('notification.channels.saved')); + onSaved?.(saved); + onClose(); + } catch (error) { + setValidationProblems([errorMessage(error, t('common.unknownError'))]); + } finally { + setSaving(false); + } + }; +``` + +`test()` analog, mit `setValidationProblems([])` statt `setValidationMessage(null)` und +`setValidationProblems(problems)` statt des `join`. + +Damit fallen beide `dangerouslySetInnerHTML` weg. + +#### 12.2 Die Meldungen bekommen zwei Orte + +Zwei Banner sind Kontext (gilt für diesen Kanal, unabhängig davon, was der Benutzer gerade tut): +die Geteilt-Warnung und der Zugangsdaten-Hinweis. Die bleiben oben. Zwei sind Ergebnis (das hast +du gerade ausgelöst): Fehler und Erfolg. Die wandern direkt über die Fußzeile, dorthin, wo der +Blick nach dem Klick auf „Testen“ oder „Speichern“ ohnehin ist. + +Oben bleibt: + +```jsx + {showSharedWarning && ( + + )} + + {secretsHidden && ( + + )} +``` + +Ganz ans Ende des Modal-Rumpfs, hinter die Felder: + +```jsx + {/* Above the footer rather than at the top of the dialog: this is the answer to the button + that was just pressed, and on a long adapter form the top of the dialog is off screen by + the time it is. */} + {validationProblems.length > 0 && ( + ')` it into a + // string, then hand that string to `dangerouslySetInnerHTML`. It is a list; it is + // rendered as one. +
    + {validationProblems.map((problem) => ( +
  • {problem}
  • + ))} +
+ ) + } + /> + )} + + {successMessage != null && ( + + )} +``` + +Die beiden `__bannerTitle`-Überschriften („Fehler“, „Super!“) fallen weg: die Banner tragen ihre +Bedeutung in Farbe und Symbol, und eine Überschrift über einem einzigen Satz ist eine Zeile ohne +Information. `notification.errorTitle` und `notification.successTitle` bleiben in den Locales, sie +werden anderswo benutzt. + +#### 12.3 Zwei benannte Gruppen + +Die acht gleichrangigen `channelEditor__field` werden zwei Gruppen: was der Kanal ist, und womit +er sich anmeldet. + +```jsx +
+ {t('notification.channels.groupChannel')} + +
+ + +
{t('notification.channels.nameHelp')}
+
+ +
+
{t('notification.channels.typeLabel')}
+
{adapterConfig.name}
+
{adapterConfig.description}
+
+ + {currentUser?.isAdmin && ( +
+
{t('notification.channels.visibilityLabel')}
+ + +
-
+ +
@@ -275,7 +302,7 @@
-
@@ -490,7 +517,7 @@
-
+

Zimmer:

@@ -502,7 +529,7 @@
-
+

Quadratmeter:

@@ -536,10 +563,10 @@
- + - Externer Link zu Instagram + Externer Link zu Bremen.de @@ -550,82 +577,82 @@
-
-
-
- - Mietangebote +
+
+ -
+

- Umzugskartons + 5 Zi, 110m2, 2 Balkone, Gäste-WC - Walsroder Dreieck

-
UMZUGSKARTONS 
STÜCK 1.20 €
+
Die Wohnung befindet sich in Bad Fallingbostel, am Autobahnkreuz Walsrode
zum 1.01.2027 frei
5 Zi, 110m2, EBK, Wannenbad mit Dusche und Fenster, Gäste-WC, Abstellraum, Keller, PKW-Stellplatz       -            die Wohnung ist vollständig saniert.
KM 700 €  [1], NK 400 € [1], Deponat: 3 KM
In den NK sind Heizung, KaltWasser, WarmWasser, alle sonstigen Kosten - also die gesamten Nebenkosten - enthalten; bzgl. Strom ist mit den Stadtwerken ein gesonderter Vertrag zu schließen.
Die Wohnung ist für eine WG sehr gut geeignet: 2 Bäder, 2 Balkone, ZimmerAnzahl, …
Die Wohnung befindet sich in einem 8-Parteienhauses, das in einer Sackgasse steht von Bäumen und Wiese umgeben - kein Durchgangsverkehr.
2 Grundschulen, 1 Oberschule,  KinderGarten, Ärzte, ... Behörden befinden sich in unmittelbarer Nähe (zu Fuß ca. 20 Minuten).
===> Die Wohnung befindet sich in Bad Fallingbostel, am Autobahnkreuz Walsrode <===
Bis zur Autobahn A7 oder A27 werden mit dem Fahrzeug ca. 10 Minuten benötigt.
Entfernung nach Hannover, Bremen, Hamburg ca. 60 ..- 70 Km
[1] Bsp. für 3 Personen, Kaltmiete und Nebenkosten orientieren sich an Personenanzahl (bei >4Personen) und am bisherigen Verbrauch.
(Unter Umständen ist auch ein Kauf möglich.) 
- Findorff + Außerhalb Bremens +
+ +
+ + + 700 €
-
-
- - +
+
-
-
-
- - 1 +
+
+ -
+

- 2 ZKB, EBK, Balkon, 56 qm, Walle, EUR 565,00 + Wunderschöne 2 Zimmer Wohnung in Bremen Findorf

-
Ab sofort oder später wird im Achelisweg 7 eine 
2-ZIMMER-WOHUNG ( 56 m² ) mit folgender Ausstattung vermietet: 
- Erdgeschoss 
- Einbauküche mit E-Geräten 
- neuwertiges Duschbad 
- Balkon 
- Kellerraum 
- Fahrradunterstand 
Miete: € 565,00 + NK: € 110,00 zzgl. Heizung und Strom 
Bei Interesse bitte unter Tel. 0173 632 41 87 melden. 
+
Wir vermieten hier in einem Mehrfamilienhaus eine 2 Zimmer Wohnung die komplett saniert wurde. Die Wohnung ist ca.60 qm groß und die Kaltmiete ist 900 € warm 1100 € nur an berufstätige abzugeben. Die Küche bringt jeder Mieter selbst mit. 
-
- - Walle + +
+ + + 900 €
-
-
- - +
+
@@ -643,10 +670,10 @@

- + - Externer Link zu Bremen.de + Externer Link zu Instagram @@ -656,197 +683,185 @@

-
-
-
- - Küche +
+
+ -
+

- Charmante 2-Zimmer-Altbauwohnung in der Bremer Neustadt – 48 m² zum 15.10. + Suche eine Wohnung in Bremen

-
Charmante 2-Zimmer-Altbauwohnung in der Bremer Neustadt – 48 m² zum 15.10.

Ich suche einen/e Nachmieter/in

Zur Vermietung steht eine schöne 2-Zimmer-Altbauwohnung mit ca. 48 m² in attraktiver Lage in der Bremer Neustadt, in unmittelbarer Nähe zu den Neustadtswallanlagen. Kaltmiete 630€ inklusive Nebenkosten. 

Die Wohnung überzeugt durch ihre hohen Decken und den typischen Charme eines Bremer Altbaus. Sie verfügt über zwei gut geschnittene Zimmer, eine Küche, ein Badezimmer mit Badewanne sowie einen Flur.

Ausstattung:

  • ca. 48 m² Wohnfläche
  • 2 Zimmer
  • hohe Decken und schöner Altbaucharme
  • separate Küche
  • Badezimmer mit Badewanne
  • Flur

Die vorhandene Einbauküche soll für 750 € übernommen werden. Im Preis sind die vorhandenen Elektrogeräte enthalten:

  • Spülmaschine
  • Cerankochfeld
  • Backofen

Lage:

Die Wohnung befindet sich in einer sehr schönen und zentralen Lage in der Bremer Neustadt. Die Neustadtswallanlagen befinden sich in unmittelbarer Nähe und laden zum Spazieren und Entspannen ein.

Sowohl die Bremer Innenstadt als auch die Weser sind bequem zu Fuß erreichbar. Einkaufsmöglichkeiten und öffentliche Verkehrsmittel befinden sich ebenfalls in fußläufiger Entfernung.

Trotz der zentralen Lage sind Parkmöglichkeiten in der Straße in der Regel gut zu finden.
+
Hallo, mein Name ist Richard bin 22 Jahre alt und komme ursprünglich aus Berlin. Ich bin für meine Ausbildung im
August nach Bremen gezogen und suche daher immer noch nach einer Bleibe. Deshalb würde ich mich über jede Hilfe freuen damit ich auch mal ein Haken dran setzen kann.
-
- - Neustadt -
- +
- 560 € + 600 € VB
-
- - +
+
-
-
-
- +
+
+ -
+

- RH Woltmershausen, 4 Zimmer, Bad/Toilette, EBK, Garten + Wohnung ab 1.10.2026 zu vermieten

-
RH Woltmershausen, ruhige Wohnstrasse, Fussweg zum Bus 7 Minuten, Fahrzeit zum Domshof 12 Minuten, nahe Schulzentrum.
4 Zimmer, Bad/Toillette, EBK, Waschküche, Keller, Kellergarage (PKW-Stellplatz), Terrasse (4,5 x 4,5 m), Garten (10 x 5,5 m).
Wohnfläche 85 m² (Erdgeschoss, 1. Stock, Dachgeschoss); Keller/Flure Nutzfläche 30 m², Vorgarten wird z. Zt. sarniert.
Das Haus wurde sehr aufwändig vor 6 Jahren renoviert ( Alle Böden, Türen , Küche ,Bad, Malerarbeiten (Innen) neu).
Miete (Kalt) 1100,00 plus 150,00 € Nebenkosten. Strom, Gas und Wasser extra.
Baujahr 1956. Gasheizung. Bisheriger Energieverbrauch 115,5 kWh/(m²a)
+

73qm Wohnung in Bremen-Mahndorf
ab 1.10.2026 zu vermieten. 
2 Zimmer, Kü., Bad, Keller, überdachte Terrasse, Fahrradabstellplatz 
Eine neue Gasheizung beheizt das Haus.
Wasser wird durch eine Photovoltaik- Anlage erwärmt.
Energieausweis ist vorhanden.
Die Fenster haben Außenjalousien. 
Eine Gegensprechanlage mit Türöffner ist vorhanden.
Die Wohnung ist barrierefrei.
Die Wohnung befindet sich in einem Mehrfamilienhaus mit drei Parteien auf einem Gartengrundstück.
 
Fussläufig sind Zug, Bahn, Bus gut zu erreichen. 
Ebenso Weserpark, Schule, Kindergarten, Sportplätze.
Bitte bewerben Sie sich per mail: [email protected]
- Woltmershausen + Hemelingen
- 1.100 € + 700 €
-
- - +
+
-
-
-
- +
+
+ -
+

- Dachwohnung in Lilienthal auf Bauerhof + Wohnung zur Untermiete

-
Dachwohung 45 m/2 auf Bauerhof frei 
+
Hallo, ich biete meine schöne DG-Wohnung mit gemütlichem Charme zur Untermiete für 4 Wochen an: 5.10.–5.11.
Sie hat 2 Zimmer und befindet sich zentral nahe der Wilhelm-Kaisen-Brücke und alles, was man braucht, ist fußläufig entfernt.
Ich suche eine Person, die achtsam mit meinen Möbeln umgeht und Nichtraucherin ist. 
Die Miete ist 700 Euro warm. 
Bitte schreib etwas zu dir und stell gerne Fragen.
Grüße!
- +
+ + Neustadt +
+
- 600 € + 700 € VB
-
- - +
+
-
-
-
- - Zimmer +
+
+ -
+

- 3-Zimmer-Wohnung in idyllischer Lage in Findorff + 2,5 Zimmer Wohnung zu vermieten

-
3-Zimmer-Wohnung in idyllischer Lage 
Gustav-Heinemann-Straße 

Die hochwertige 3-Zimmer-Wohnung befindet sich im Hochparterre eines gepflegten Wohnhauses in idyllischer und ruhiger Lage. 
Über den einladenden Eingangsflur gelangt man in den großzügigen Wohnbereich mit Zugang zum überdachten Balkon. 
Die separate Küche ist mit einer modernen Einbauküche ausgestattet. Für zusätzlichen Stauraum sorgt ein praktischer Abstellraum innerhalb der Wohnung. 
Neben dem großzügigen Wohnzimmer und der Küche verfügt die Wohnung über ein helles Schlafzimmer sowie ein weiteres Zimmer, das sich ideal als Kinder-, Arbeits- oder Gästezimmer eignet. 
Das Badezimmer ist mit einer Badewanne und einem Fenster ausgestattet. 
Die Wohnräume sind mit hochwertigem Parkettboden ausgestattet. Küche und Badezimmer verfügen über einen pflegeleichten Fliesenbelag. 
Ein Tiefgaragenstellplatz zur Miete ist zusätzlich vorhanden. 
Kaltmiete (monatlich): 1.280,00 €
Tiefgaragenstellplatz   (monatlich): 85,00 € 
+ Nebenkosten
Highlights auf einen Blick: 
  • Hochwertige 3-Zimmer-Wohnung im Hochparterre
  • Großzügiger Wohnbereich
  • Überdachter Balkon mit Blick ins Grüne
  • Separate Küche mit Einbauküche
  • Praktischer Abstellraum
  • Schlafzimmer und Kinder-/Arbeitszimmer
  • Badezimmer mit Badewanne und Fenster
  • Hochwertiger Parkettboden
  • Tiefgaragenstellplatz 
  • Fliesen in Küche und Badezimmer

 
+

Moin, 
 
Wir haben eine helle 2,5 Zimmer Wohnung zu vermieten. Es handelt sich um eine Wohnung mit ca. 75m², verteilt auf 2 Etagen mit Duschbad, Küche und einem Balkon. 
 
Die Kaltmiete beträgt 690€ + aktuell 185€ Nebenkkosten (inklusive Strom). 
 
Die Wohnung befindet sich in Bremen Woltmershausen, ruhig in einer Seitenstraße gelegen. 
 
 Sollten Sie Interesse haben, senden Sie uns bitte eine Mieterauskunft, Schufa und Mietschuldenfreieheitsbescheinigung. "Bewerbungen" ohne diese Dokumente können leider nicht berücksichtigt werden. 
 
 Vielen Dank und vielleicht sieht man sich bald zur Besichtigung, 
 
A Kropp 
- Findorff + Woltmershausen
- 1.280 € + 690 €
-
- - +
+
-
-
-
- - Eingang +
+
+ -
+

- Großzügiges Haus in Bremen-Schwachhausen – ca. 170 m² + Wohnung in Schachhausen zu vermieten

-
Haus in Schwachhausen zu vermieten: ca. 170 m², 6 Schlafzimmer, 2 Bäder + Gäste-WC, Garage/Stellplatz, Keller. Vielseitig nutzbares Objekt mit zahlreichen Nutzungsmöglichkeiten. 

+
Ab sofort ist eine lichtdurchflutete, ruhige, charmante, abgeschlossene 2 1/2 Zimmerwohnung, ca.65 m2, 2.Etage, mit Waschküchenbenutzung und viel Stauraum frei. Vereinzelte Schrägen, Raumhöhe bis 2,60m .Eventuell Gartenmitbenutzung ,traumhaftem Garten, welche auch 4 Hühner beherbergt,  zur Freude oder Mitversorgung. Im Haus wohnt noch Kater Anton, ein äußerst ruhiger und liebenswerter Freigänger.
Miete 620€/150 kalt/warm.
Gewünscht wird ein freundlicher ruhiger und offener  Mitbewohner/-in, welcher sich vorstellenkann, in einem Stilvoll renovierten Altbremer Haus in einer Hausgemeinschaft zu leben und gerne in Kontakt geht,welcher aber auch genauso gerne für sich ist.
@@ -855,66 +870,65 @@

Schwachhausen

- +
+ + + 620 € +
+
-
- - +
+
-
-
-
- - Badezimmer +
+
+ -
+

- Erstbezug nach Sanierung – 2-Zimmer-Wohnung mit Balkon direkt am DIAKO + 3 Zimmer Wohnung 80qm Hochpaterre zum 1.11.

-
Objektbeschreibung 
Zur Vermietung steht eine umfassend sanierte 2-Zimmer-Wohnung mit ca. 60 m² Wohnfläche im 3. Obergeschoss eines Mehrfamilienhauses. Die Wohnung wird nach Abschluss der Sanierung erstmals bezogen. Ein Aufzug ist nicht vorhanden. 
Sie verfügt über ein helles Wohnzimmer mit direktem Zugang zum Balkon, ein Schlafzimmer, eine separate Küche mit Fenster, ein neues Badezimmer sowie ein separates WC. Das Wohnzimmer und der Balkon liegen auf der Sonnenseite. Vom Balkon aus besteht ein direkter Blick auf das gegenüberliegende DIAKO Krankenhaus. 
Lagebeschreibung 
Die Wohnung befindet sich direkt gegenüber dem DIAKO Krankenhaus. Eine Bushaltestelle liegt unmittelbar vor der Tür. Einkaufsmöglichkeiten für den täglichen Bedarf befinden sich in der näheren Umgebung. 
Auch die Waterfront sowie die Autobahn sind schnell erreichbar. Die Lage ist besonders praktisch für Beschäftigte des DIAKO, eignet sich jedoch selbstverständlich ebenso für alle anderen Interessierten. 
Bemerkung zur Ausstattung 
Die Wohnung wurde umfassend erneuert. Im Rahmen der Sanierung wurden unter anderem folgende Arbeiten durchgeführt: 
  •  komplett neue Elektroinstallation 
  •  neue Heizkörper 
  •  neu verputzte und glatt gespachtelte Wände 
  •  neuer Vinylboden im Wohn-, Schlaf- und Flurbereich 
  •  neu geflieste Küche 
  •  vollständig erneuertes Badezimmer und separates WC 
  •  neue Fliesen, Sanitäreinrichtungen und Anschlüsse 
  •  Netzwerkanschlüsse im Wohn- und Schlafzimmer 

Neue Zimmertüren sind bereits bestellt und werden zeitnah eingebaut. Die Netzwerkanschlüsse im Wohn- und Schlafzimmer eignen sich ideal für Homeoffice, Streaming und weitere internetgebundene Anwendungen.
 
Sonstiges
 
Die Beheizung erfolgt über Fernwärme.
 
Energieverbrauchskennwert: 150 kWh/(m²·a)
Energieeffizienzklasse: E
 
Besichtigungstermine können kurzfristig vereinbart werden. Der Einzug ist ab sofort oder nach Vereinbarung möglich.
 
Kaltiete: 800 €
Nebenkosten 160 € (inkl. Heizung)
Kaution 2100 €
+
Wir suchen Nachmieter*innen für unsere 80qm Hochpaterre Wohnung zum 1.11. 
Zu den 80qm kommen noch zwei geräumige Kellerräume, ein Heizungsraum (für Waschmaschine und als Trockenraum gut geeignet), sowie ein kleiner Garten im Innenhof. Die 3 ZKB verteilen sich auf zwei Ebenen.
Nicht WG-geeignet, auf Wunsch des Vermieters kein Jobcenter
Kaltmiete aktuell 1300€+200€ Nebenkosten (inklusive Heizung und Wasser) 
- Gröpelingen + Findorff
- 800 € + 1.300 €
-
- - +
+
@@ -945,92 +959,81 @@

-
-
-
- - Wohnzimmer +
+
+ -
+

- Dreizimmerwohnung, Johanbornemacherstr/Steffensweg + Single-Wohnung, ca. 50qm, 2 Zimmer mit Balkon, Neue Vahr

-
Objektbeschreibung 
Helle und gut geschnittene Wohnung mit ca. 68 m² Wohnfläche in ruhiger und dennoch zentraler Wohnlage in Bremen-Walle (Ortsteil Westend). Die Wohnung bietet ein angenehmes Wohnumfeld und eignet sich ideal für Singles, Paare oder kleine Familien. 
Eckdaten & Kosten 
Wohnfläche: ca. 68 m² 
Zimmer: 3 
Kaltmiete: 759,00 € 
Nebenkosten: 250,00 € 
Verfügbar ab: 01.10.2026
Ausstattung  
Einbauküche vorhanden
 Balkon vorhanden
Lage 
Die Immobilie befindet sich in der Johann-Bornemacher-Straße im begehrten Stadtteil Walle
Einkauf & Alltag: Supermärkte, Bäckereien, Ärzte und das Walle-Center sind bequem und schnell erreichbar. 
Anbindung: Haltestellen des ÖPNV befinden sich in fußläufiger Entfernung und bieten eine schnelle Direktverbindung in die Bremer Innenstadt und zum Hauptbahnhof. 
Freizeit: Die beliebte Überseestadt mit Restaurants, Kulturangeboten und der Weserpromenade liegt in unmittelbarer Nachbarschaft. 

+
Single-Wohnung, ca. 50qm, 2 Zimmer mit Balkon, Neue Vahr Nord 
Kaltmiete 520Euro 
Frei ab 01.10.2026 
Gut geschnittene 2-Zimmer-Wohnung mit Kellerraum und gemeinschaftlicher Raum für Fahrräder. Die Wohnung ist frisch renoviert, Laminat und Raufaser tapeziert, weiß gestrichen. Neue Einbauküche und Bad mit Dusche in sehr gutem Standard. 
Mehrfamilienhaus Bj.1960, energetisch saniert, Hochparterre, Fernwärme, Isolierverglasung, Gegensprechanlage, Bad innenliegend, Balkon, Fahrradkeller, Bushaltestelle und Einkaufsmöglichkeit ca.3min Fußweg 
Energieausweis: E 
Nebenkosten 85€/Heizkosten 120€ 
Kaution 3x Kaltmiete 
Grundriss 
- Walle + Vahr
- 759 € + 520 €
-
- - +
+
-
-
-
- - WZ +
+
+ -
+

- Charmantes Appartement über den Dächern Bremens + Abzugeben an WG: Tolle 6 Zimmer Altbau-Wohnung mitten im Viertel

-
Diese lichtdurchflutete 35 qm Dachgeschosswohnung vereint den typischen Bremer Altbaucharme mit modernem Wohnkomfort. Dank hoher Decken und großer Fenster wirken die Räume besonders großzügig und einladend. 
Ausstattung & Highlights: 
  • Wohnkomfort: Großzügiger Wohn- und Schlafbereich sowie eine geräumige Wohnküche.
  • Einbauküche: Voll ausgestattet mit E-Herd und Kühlschrank.
  • Extra-Zimmer: Ein kleiner separater Raum, der sich perfekt als begehbarer Kleiderschrank oder Home-Office eignet.
  • Badezimmer: Modernes, helles Duschbad mit wandhängendem WC und integriertem Waschmaschinenanschluss.
  • Eingang: Praktischer Windfang im Entree für zusätzliche Privatsphäre und Stauraum.

Mietdetails:
 
  • Kaltmiete: 600 €
  • Warmmiete: 700 € (inklusive Heizkosten; exklusive Strom und Internet/Telefon)

+
Zu November ziehen wir aus unserer schönen Wohnung im Viertel aus. 
Sie ist groß und hat sechs unterschiedlich große Zimmer, verfügt über 3 Balkone und zwei Bäder. 
Aus der aktuellen WG-Konstellation bleibt vorerst eine Person wohnen. Deshalb werden fünf neue Personen gesucht. Wir freuen uns, wenn diese sich vorher schon gefunden haben - Anfragen also bitte erstmal nur als bereits bestehende Gruppe! Es kann gerne zeitnah besichtigt werden oder bei ernsthaftem Interesse auf Nachfrage noch ein paar Bilder geschickt werden :) 
die Gesamtmiete beträgt aktuell knapp 2.600€ 
- Östliche Vorstadt -
- -
- - - 600 € + Mitte
+
-
- - +
+
@@ -1048,10 +1051,10 @@

- + - Externer Link zu Pinterest + Externer Link zu Veranstaltungskalender @@ -1074,8 +1077,8 @@

3 … - - 12 + + 10

@@ -1107,7 +1110,7 @@

Telefon: 0421 - 9600 - 880
- E-Mail: [email protected]

+ E-Mail: [email protected]

© 2026 Bremen Online
eine Abteilung der WFB Wirtschaftsförderung Bremen GmbH

@@ -1125,19 +1128,20 @@

- - - - - - - - - - - - - - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/testFixtures/schwarzesbrett_detail.html b/test/testFixtures/schwarzesbrett_detail.html index f74edb62..d6326ddd 100644 --- a/test/testFixtures/schwarzesbrett_detail.html +++ b/test/testFixtures/schwarzesbrett_detail.html @@ -1,4 +1,35 @@ - + + + + 5 Zi, 110m2, 2 Balkone, Gäste-WC - Walsroder Dreieck - Schwarzes Brett auf bremen.de - der Kleinanzeigenmarkt für Bremen + + + + + + + + + + + + + + + + + + + +
-