Compare commits

..

19 Commits

Author SHA1 Message Date
syntaxbullet
a1285957ef fix(backup): add terminal allocation for SSH command 2026-07-31 20:04:52 +02:00
syntaxbullet
6915be534d test(release): verify client feedback rollout 2026-07-31 19:33:46 +02:00
syntaxbullet
07e9458aaf fix(seo): correct sitemap routes 2026-07-31 19:03:07 +02:00
syntaxbullet
0a19abd0b0 fix(about): align facts and caption 2026-07-31 18:46:13 +02:00
syntaxbullet
3c28f6ff9c fix(about): rebalance relevance section 2026-07-31 18:26:25 +02:00
syntaxbullet
4bb265dac1 fix(participation): clarify eligibility hierarchy 2026-07-31 18:07:05 +02:00
syntaxbullet
d7a720460f docs(home): verify application panel color 2026-07-31 17:40:42 +02:00
syntaxbullet
5b62abf8d4 fix(home): rebalance application form split 2026-07-31 17:34:31 +02:00
syntaxbullet
f465839af0 fix(home): honor phase headline line breaks 2026-07-31 17:24:36 +02:00
syntaxbullet
adda9ddbd0 fix(home): slow sponsor ticker 2026-07-31 17:04:27 +02:00
syntaxbullet
95037e9908 feat(cms): clarify sponsor ordering 2026-07-31 16:52:49 +02:00
syntaxbullet
30efb4b36e feat(network): add responsive sponsor hierarchy 2026-07-31 16:41:02 +02:00
syntaxbullet
ebecf66e6f fix(participation): repair process timeline 2026-07-31 15:40:21 +02:00
syntaxbullet
c2dc42f243 feat(cms): add press contact image 2026-07-31 15:21:21 +02:00
syntaxbullet
e3c47c4500 feat(cms): add network page video upload 2026-07-31 15:08:34 +02:00
syntaxbullet
ab47571139 refactor(cms): remove obsolete page fields 2026-07-31 14:51:11 +02:00
syntaxbullet
8dc17c0fea fix(cms): remove unpublished content fallbacks 2026-07-31 14:25:11 +02:00
syntaxbullet
b18bbbe36a fix(cms): filter unpublished relationships 2026-07-31 14:10:44 +02:00
syntaxbullet
fec1bab2e5 fix(cms): enforce public publication contract 2026-07-31 14:01:18 +02:00
83 changed files with 5595 additions and 2127 deletions

View File

@@ -0,0 +1,275 @@
# Client feedback rollout: deployment readiness
Verification date: 2026-07-31
## Decision
Tasks 1–17 are ready for a coordinated schema-changing deployment. Local code,
CMS ownership, isolated database migrations, the full serial Vitest integration
suite, the production webpack build, generated sitemaps, and public/draft
behavior have been verified.
The rollout is not yet complete in production. Production is still serving
revision `740e302c58f8`, before Tasks 1–17, and the production-write gates listed
below remain intentionally blocked. No production database, media, publication
state, or deployment was changed during Task 18.
## Commit sequence
The clean root worktree and this verification worktree both started at
`07e9458aaf2796f5f48a69302acd26914032b879`. The root `main` history contains
exactly these 17 Task 1–17 commits after production revision `740e302c58f8`:
| Task | Commit | Subject |
| --- | --- | --- |
| 1 | `fec1bab2e521d38af9de3c7c3c315c0da91795e7` | `fix(cms): enforce public publication contract` |
| 2 | `b18bbbe36ad395cb26011d6032e06f460ded3f2a` | `fix(cms): filter unpublished relationships` |
| 3 | `8dc17c0fea9420b2854a074d3924d4341147f04b` | `fix(cms): remove unpublished content fallbacks` |
| 4 | `ab47571139a177339a002ff62336a61f7a44d4c6` | `refactor(cms): remove obsolete page fields` |
| 5 | `e3c47c4500150be0fb40caa6cb4ae2619f61789c` | `feat(cms): add network page video upload` |
| 6 | `c2dc42f243e58c7cb81e2e23e2aa2e34110ca4fc` | `feat(cms): add press contact image` |
| 7 | `ebecf66e6fcb8514d5206bfc80ad36f4c9939e4e` | `fix(participation): repair process timeline` |
| 8 | `30efb4b36ee7c0187c0274f319679846287ae3cb` | `feat(network): add responsive sponsor hierarchy` |
| 9 | `95037e99089837a64a4315f6d2a467e8e5be61f5` | `feat(cms): clarify sponsor ordering` |
| 10 | `adda9ddbd07a7bb04947c349a2117dd31879d190` | `fix(home): slow sponsor ticker` |
| 11 | `f465839af0e49fa5021272c0a85786a90a763589` | `fix(home): honor phase headline line breaks` |
| 12 | `5b62abf8d4b205d8603f3a0d34393d47d30412e8` | `fix(home): rebalance application form split` |
| 13 | `d7a720460f542fc6c34ad3bd46d2eb5be1e2be6e` | `docs(home): verify application panel color` |
| 14 | `4bb265dac1b7862db1e468d63126a07e9eaa8aec` | `fix(participation): clarify eligibility hierarchy` |
| 15 | `3c28f6ff9c53486b87c2d45ffb57e35bc5a025d7` | `fix(about): rebalance relevance section` |
| 16 | `0a19abd0b080095748f1de28dfe829da62316e96` | `fix(about): align facts and caption` |
| 17 | `07e9458aaf2796f5f48a69302acd26914032b879` | `fix(seo): correct sitemap routes` |
Task 18 is the single commit containing this report and the regression-test
cleanup described below. Its exact hash is recorded in the Task 18 handoff
because a commit cannot embed its own hash.
No Task 1–17 integration is missing, and no unrelated tracked changes were
present at the start of Task 18.
## Automated verification
All database-backed checks used copies of the current local `content.db`. The
source database SHA-256 was
`fb7de5d9f1fcf3a7c27494efe7f3111cba4173bc986a38ea62c9cf6d5840425e` and was
never migrated or otherwise mutated.
### Integration tests
- Focused publication, relationship, sitemap, CMS-field, media, responsive
section, timeline, sponsor, press, home, and about tests passed.
- Complete serial suite against a freshly copied and migrated database:
**21 files, 92 tests, all passed**.
- Serial command shape used to avoid SQLite parallel-write contention:
```bash
DATABASE_URL=file:/absolute/path/to/isolated.db \
PAYLOAD_SECRET=task18-isolated-verification \
PAYLOAD_MIGRATING=true \
SITE_URL=http://127.0.0.1:3100 \
./node_modules/.bin/vitest run \
--config ./vitest.config.mts \
--maxWorkers=1 \
--no-file-parallelism \
--hookTimeout=30000
```
`PAYLOAD_MIGRATING=true` is required for tests against an explicitly migrated
database so Payload does not also attempt its development schema-push path.
### Task 18 regression fix
`tests/int/publication-contract.int.spec.ts` created unique publication fixtures
but deleted only their media record. A second run against the same isolated
database therefore failed unique `slug` and `stableId` validation. Task 18 now
deletes every created document before deleting the media record. The focused
suite passed twice consecutively afterward, and the complete 92-test serial
suite remained green.
### Static and build checks
- `./node_modules/.bin/tsc --noEmit --incremental false`: passed.
- `payload generate:types`: passed; `src/payload-types.ts` was byte-for-byte
unchanged.
- `git diff --check`: passed.
- Task 18 changed-file Prettier check: passed.
- Production webpack build against the migrated database:
`next build --webpack`: passed and generated all expected application,
Payload, preview, and sitemap routes.
- Production-safe sitemap postbuild with
`NEXT_PUBLIC_SERVER_URL=https://srv1.bayerischer-mittelstandspreis.de`:
passed. Generated `robots.txt` and `sitemap.xml` contain only the canonical
HTTPS origin.
- Local generated and dynamic sitemap endpoints returned `200`; `/`, the
representative article, event, and winner URLs were present, no localhost or
placeholder origin was present, and the unpublished Task 18 fixture was
absent.
## Migration verification
Initial status on the copied current database was 5 recorded migrations and 18
pending migrations. The isolated upgrade completed all pending migrations and
ended with all 23 repository migrations recorded. `PRAGMA integrity_check`
returned `ok`.
Every migration introduced by Tasks 1–17 was additionally verified through its
own copied-database down/up cycle, with integrity checks after both directions:
- `20260731_143000_remove_press_record_fallbacks`
- `20260731_160000_focused_payload_cleanup`
- `20260731_170000_netzwerk_greeting_video`
- `20260731_180000_press_contact_image`
- `20260731_190000_responsive_sponsor_hierarchy`
- `20260731_200000_home_phase_headline_line_break`
All six targeted down/up cycles passed. The real `content.db` was never used as
a migration target.
## Browser verification
The production build was served locally against the isolated migrated database
and the copied local media directory. Anonymous and authenticated preview checks
used separate browser state.
### Route and viewport matrix
The following routes were checked at approximately 1440, 1024, 768, and an
exact 390 CSS pixels:
- `/`
- `/teilnahme`
- `/der-bmp`
- `/netzwerk`
- `/presse`
- `/presse/events/nominierten-auswahl-2026`
- `/presse/blog/goldene-bavaria`
- `/preistraeger/agrarquant-gmbh`
All 32 combinations rendered their expected CMS-owned heading and substantive
main content with zero broken images. There was no page-level horizontal
overflow except for the pre-existing event-detail issue below.
Specific checks also passed:
- Homepage phase heading rendered the CMS newline as two lines.
- Shared `#bewerben` section computed to `rgb(36, 54, 106)` (`#24366A`).
- `/teilnahme` timeline advanced from `01 / 04` through `04 / 04`; all four
desktop/tablet steps were reachable, the 1024px section was 667px tall, and
the next-section gap was `0`. At 390px all four mobile headings rendered in
document order, controls were hidden, and the next-section gap was `0`.
- Eligibility groups and the dates transition remained in the intended mobile
scan order.
- `/der-bmp` relevance layout was split on desktop; all three facts were
centered and the image caption had a visible dark controlled background.
- `/netzwerk` rendered tier bands with current counts 2 / 2 / 8 in authoritative
order. With no Netzwerk video selected, no video action rendered.
- `/presse` rendered the safe `copy-only` contact layout with no contact image
selected.
### Anonymous public versus authenticated preview
An isolated draft press article was created with slug
`task18-preview-fixture`.
- Anonymous direct browsing returned the 404 page and exposed neither the
fixture title nor body.
- After authenticating an isolated Payload user and entering through
`/next/preview`, the same path rendered the draft title.
- Publication and relationship integration tests independently prove this
boundary for pages, winners, jury members, partners, events, and posts.
## Read-only production smoke
No production login or write was performed. Browser and HTTP checks produced:
| Production route | Result |
| --- | --- |
| `/` | `200`, expected main content, no broken images |
| `/teilnahme` | `200`, expected main content, no broken images |
| `/der-bmp` | `200`, expected main content, no broken images |
| `/netzwerk` | `200`, expected main content, no broken images |
| `/presse` | `200`, expected main content, no broken images |
| `/presse/blog/goldene-bavaria` | `200`, expected article detail |
| `/presse/events/nominierten-auswahl-2026` | `404` before rollout |
| `/preistraeger/agrarquant-gmbh` | `404` before rollout |
| `/sitemap.xml` | `200` |
| `/pages-sitemap.xml` | `200` |
| `/posts-sitemap.xml` | `200` |
| `/robots.txt` | `200` |
The read-only VPS status script confirmed deployed revision `740e302c58f8`,
which is exactly the parent of Task 1. Further container/status output requires
interactive server `sudo` and was not attempted.
The current production sitemap is therefore expectedly stale: it still emits
`/startseite`, emits three `/posts/<slug>` URLs that each return `404`, and has
no event or winner detail entries. The corrected local sitemap cannot be judged
live until the verified commit sequence is deployed.
## Known tooling and pre-existing limitations
- Repo-wide ESLint does not reach file evaluation because the current flat
configuration throws `Cannot redefine plugin "@typescript-eslint"`.
- A rollout-wide Prettier audit reports 41 already-committed Task 1–17 files as
nonconforming. Task 18's changed files pass Prettier; bulk reformatting was
intentionally excluded from this release-verification commit.
- The installed global pnpm is `11.9.0`, while this repo requires pnpm `^9` or
`^10`. `pnpm vps:status` is blocked by the engine check; invoking the
read-only script directly reached production and confirmed the revision.
- The in-app browser enforces a 480px minimum CSS viewport. The exact 390px
matrix was completed through the installed Chrome browser surface instead.
- In-app navigation to local XML endpoints was blocked by the browser client;
read-only HTTP checks verified all four sitemap/robots endpoints instead.
- The representative event detail overflows horizontally at exactly 768px:
the pre-existing `EventDetail.tsx` layout combines 80px side padding, a 64px
gap, and a fixed 400px sidebar. Tasks 1–17 did not change this component, so
Task 18 did not expand scope to redesign it. The same route has no overflow
at 390, 1024, or 1440px.
## Required production procedure and blocked gates
These actions are intentionally not performed by Task 18. They require explicit
production-write coordination and a supported pnpm `^9` or `^10` runtime.
1. Obtain explicit production-write authorization and identify the exact commit
to deploy.
2. Create the documented server-side SQLite backup:
```bash
pnpm vps:backup-db
```
3. Deploy the committed schema-changing rollout with migrations enabled:
```bash
RUN_MIGRATIONS=1 pnpm deploy:vps
```
Per `docs/vps-deployment.md`, this mode also creates a pre-migration database
copy before `pnpm payload migrate` runs in the one-off app container.
4. Confirm the deployed revision, container health, database/media state, and
logs:
```bash
pnpm vps:status
```
5. Obtain the client-supplied Netzwerk video, upload/select it through Payload
on the Netzwerk page, and verify that changing it does not affect the
homepage video. No video asset was supplied in this task.
6. Obtain the approved press-contact image, upload/select it with useful alt
text through Payload on the Presse page, and verify the two-column desktop
and stacked mobile layouts. No image asset was supplied in this task.
7. Identify approved disposable production records before testing Unpublish and
Republish. Record IDs and `_status`, verify anonymous list/detail removal,
verify authenticated preview visibility, republish, and confirm restoration.
No disposable record was approved in this task.
8. Re-run the production route, sitemap, robots, media, responsive, and
publication smoke matrix after deployment. In particular, every URL emitted
by the corrected sitemap must return `200`.
Blocked gates at handoff are therefore: client Netzwerk video, client press
image, approved production unpublish record(s), supported deployment tooling,
interactive server privileges where required, and explicit production-write
authorization.

View File

@@ -0,0 +1,393 @@
# Client feedback implementation task list
This plan translates the client feedback from `Feedback.xlsx` into implementation tasks for the current Payload CMS and Next.js codebase.
## Confirmed decisions
- Unpublishing any draft-enabled CMS document must remove it from the anonymous public frontend.
- Authenticated draft/live preview may continue to show unpublished content intentionally.
- The CMS cleanup is focused: remove obsolete or non-rendered controls, keep page-specific content on its page, and keep content shared by multiple pages in Globals.
- `/netzwerk` receives its own CMS video upload instead of reusing the homepage video.
- Images and videos introduced by this work must be selectable through Payload's Media collection.
- Sponsor hierarchy needs a responsive design checkpoint before implementation.
## Definition of done
- An anonymous visitor cannot see an unpublished page, winner, jury member, partner, event, or press article in a list, relationship-driven section, direct detail route, sitemap, or fallback UI.
- An editor can still preview drafts through Payload preview/live preview.
- Payload exposes only controls that affect the current frontend or an explicitly retained global contract.
- New press and Netzwerk media can be selected and replaced in Payload without developer assistance.
- All visual feedback is verified at desktop, tablet, and mobile widths.
- Every schema change has a committed migration tested against a copy of the current SQLite database.
## Recommended implementation order
### 1. Establish the publication contract
This is the highest-priority work because later CMS cleanup must not hide the underlying visibility problem.
- [ ] Reproduce unpublishing from the Payload admin for every draft-enabled collection:
- [ ] `pages`
- [ ] `preistraeger`
- [ ] `jury-mitglieder`
- [ ] `partners`
- [ ] `events`
- [ ] `posts`
- [ ] For every reproduction, record:
- [ ] the document ID and `_status` before and after clicking **Unpublish**
- [ ] whether the admin request actually saves `_status = draft`
- [ ] behavior in an anonymous/private browser session
- [ ] behavior in Payload preview or a browser carrying a draft-mode cookie
- [ ] behavior on the collection/list page and direct detail URL
- [ ] Confirm that the reported problem is not only draft preview doing what it is designed to do.
- [ ] Add a table-driven integration test that queries each collection as an anonymous public request and proves that only `_status = published` documents are returned.
Acceptance criteria:
- [ ] Directly unpublishing a document changes its stored `_status` to `draft`.
- [ ] Anonymous Local API reads enforce access control with `overrideAccess: false` and `draft: false`.
- [ ] Draft preview is the only frontend context allowed to request unpublished documents.
### 2. Fix public route hydration and relationship resolution
The public route currently loads the primary document plus supporting collections in `src/app/(frontend)/[[...slug]]/page.tsx`. Make the public/draft boundary explicit rather than relying on implicit parameter combinations.
- [ ] Split public and preview query options into a small, testable helper:
- [ ] public: `draft: false`, `overrideAccess: false`
- [ ] preview: drafts enabled only when Next draft mode is active
- [ ] Apply the same contract to:
- [ ] the route document
- [ ] support pages
- [ ] winners
- [ ] events
- [ ] press articles
- [ ] partners
- [ ] jury members
- [ ] Ensure relationship-populated objects cannot bypass the filtered public collection.
- [ ] Update `resolveCmsRelationship(...)` and/or the consuming selection helpers so a relationship ID missing from the published collection resolves to nothing, never to the embedded relationship object.
- [ ] Keep the existing jury page selection authoritative, but silently omit selected jury members that are unpublished.
- [ ] Add regression tests for:
- [ ] unpublished jury member selected on `/netzwerk`
- [ ] unpublished winner selected on the homepage or `/der-bmp`
- [ ] unpublished partner previously present in the homepage ticker
- [ ] unpublished event previously present on `/presse`
- [ ] unpublished press article previously present on `/presse`
Acceptance criteria:
- [ ] Unpublishing one related item removes only that item and preserves the remaining published selection order.
- [ ] Republishing the item restores it without requiring a page document edit.
### 3. Remove collection fallbacks that resurrect unpublished content
Fallbacks for a missing image or missing optional field may remain. Entire fallback records must not replace intentionally empty CMS collections.
- [ ] Change `/presse` so an empty published `events` collection renders an intentional empty state or no event rows instead of `pressIndex.events.fallbackItems`.
- [ ] Change `/presse` so an empty published `posts` collection renders an intentional empty state or no news cards instead of `pressIndex.news.fallbackItems`.
- [ ] Remove the event and news fallback-record arrays from `pressIndexFields.ts` after the collection-backed render path is authoritative.
- [ ] Remove their preload/seed plumbing from `preload-press-index-page-cms.ts` and fresh-database setup.
- [ ] Audit winner, jury, and partner surfaces for the same pattern.
- [ ] Preserve only presentation fallbacks that cannot make a removed CMS record reappear, such as a placeholder image on an otherwise published document.
Acceptance criteria:
- [ ] Unpublishing the final event, article, or related record produces an honest empty state.
- [ ] No demo/default record appears merely because the CMS collection became empty.
### 4. Complete the focused Payload CMS cleanup
Create a field-to-renderer inventory before deleting schema. Every retained field must have a current frontend consumer or a documented global purpose.
#### Startseite
- [ ] Keep the page-specific hero, winners, quick check, intro, testimonials, benefits, and homepage video controls.
- [ ] Remove the hidden legacy `home.application` group now owned by the `application-form` Global.
- [ ] Remove the hidden legacy `home.form` group now owned by the `application-form` Global.
#### Teilnahme
- [ ] Keep hero, process, eligibility, application paths, dates, and awards.
- [ ] Remove the hidden legacy `participation.applicationForm` group.
- [ ] Remove the hidden legacy `participation.form` group.
#### Der BMP
- [ ] Remove the hidden legacy `about.highlights` group because the rendered winners section reuses the homepage source of truth.
#### Presse
- [ ] Remove obsolete press-kit controls that no longer render:
- [ ] `kitLabel`
- [ ] `kitMeta`
- [ ] `kitFile`
- [ ] `kitUrl`
- [ ] `kitDownloadName`
- [ ] Remove the complete non-rendered `pressIndex.accreditation` group.
- [ ] Remove the fallback-record arrays covered by Task 3.
- [ ] Keep the rendered section headings, descriptions, status labels, missing-image controls, and press-contact copy.
#### Netzwerk
- [ ] Remove confirmed non-rendered fields after the field-to-renderer audit, including current candidates:
- [ ] `jury.note`
- [ ] `partners.premiumLabel`
- [ ] `partners.defaultLogoColor`
- [ ] sponsoring membership-footnote fields
- [ ] Do not remove partner tier labels, partner modal copy, jury selection, or sponsoring form copy that still renders.
#### Global ownership
- [ ] Keep shared application content in **Formulare > Bewerbungsformular**.
- [ ] Keep phase state in the **Application Phase** Global.
- [ ] Keep phase-dependent newsletter content in the **Newsletter-Formular** Global.
- [ ] Keep Header, Footer, and site-wide settings in their existing Website Globals.
- [ ] Do not create a generic “Sections” content type unless a second real consumer justifies it.
#### Schema completion
- [ ] Generate a narrow migration that removes the obsolete live-table and version-table columns/child tables.
- [ ] Update `payload-types.ts`.
- [ ] Update preload, seed, and fresh-database scripts.
- [ ] Validate migration upgrade and rollback on a copied SQLite database.
Acceptance criteria:
- [ ] The Payload admin contains no hidden legacy groups or controls with no current frontend effect.
- [ ] Shared content is edited once in a Global and is not duplicated on individual pages.
- [ ] Page editors contain only page-specific content plus standard page metadata/SEO controls.
### 5. Give `/netzwerk` its own Ilse Aigner video
- [ ] Add a video upload field under the Netzwerk patronage/greeting section.
- [ ] Filter the upload relationship to video MIME types.
- [ ] Add page-specific video button label, title, description, and close-label fields if those strings need to differ from the homepage.
- [ ] Replace the `HomeVideoCms`/homepage lookup in `Netzwerk.tsx` with the Netzwerk page fields.
- [ ] Keep the existing homepage video and modal completely independent.
- [ ] Update preload/fresh-database code without copying the existing homepage media relationship into the new field unless the client explicitly wants a temporary placeholder.
- [ ] Let the client upload and select the supplied Netzwerk video after deployment.
Acceptance criteria:
- [ ] Changing the homepage video does not change `/netzwerk`.
- [ ] Changing the Netzwerk video does not change the homepage.
- [ ] Empty Netzwerk video selection hides the video action cleanly.
### 6. Add the CMS-controlled press-contact image
- [ ] Add `contactImage` and `contactImageAlt` to the rendered Presse contact/material section.
- [ ] Use a Payload upload relationship filtered to image MIME types.
- [ ] Restore the desktop contact section to a two-column composition with copy on the left and the selected image on the right.
- [ ] Stack image and copy in a deliberate order on mobile.
- [ ] Define a safe empty-image layout so the section still works before the client selects an asset.
- [ ] Update the preload script and migration.
Acceptance criteria:
- [ ] The client can select, replace, or clear the image in Payload.
- [ ] No code edit is required to change the press image.
### 7. Repair the `/teilnahme` “Schritt für Schritt” timeline
The current desktop implementation uses a `300vh` scroll container and produces overlapping/off-screen cards.
- [ ] Replace the current per-card transform calculation with a single deterministic horizontal-track transform, or replace scroll-jacking with an explicit horizontal carousel/stepper.
- [ ] Ensure steps 1–4 are all reachable at normal wheel/trackpad speed.
- [ ] Remove the unused vertical space after the final step.
- [ ] Make the progress indicator derive from actual scroll/active-step state instead of fixed copy.
- [ ] Preserve the existing stacked mobile presentation unless testing finds a separate defect.
- [ ] Respect `prefers-reduced-motion` by providing a non-scroll-jacked interaction.
- [ ] Add a browser regression test that proves every step becomes visible and the next section follows without a large blank gap.
Acceptance criteria:
- [ ] Steps 1, 2, 3, and 4 can each be read in full.
- [ ] Cards never overlap at supported desktop/tablet widths.
- [ ] The eligibility section begins promptly after the timeline finishes.
### 8. Design and approve a responsive sponsor hierarchy
Recommended direction: preserve a hierarchy through tier bands and decreasing visual weight rather than forcing lower tiers into empty higher-tier slots. Mixing tiers within one row weakens the pyramid and produces inconsistent card widths.
#### Proposed responsive behavior
- [ ] Desktop (`>= 1200px`):
- [ ] Tier 1: up to 2 large cards per centered row
- [ ] Tier 2: up to 3 medium cards per centered row
- [ ] Tier 3: up to 4 compact cards per centered row
- [ ] Center incomplete rows rather than filling them with a different tier
- [ ] Tablet (`768–1199px`):
- [ ] Tier 1: 2 columns
- [ ] Tier 2: 2 columns
- [ ] Tier 3: 3 columns where space allows, otherwise 2
- [ ] Mobile (`< 768px`):
- [ ] one clearly labeled section per tier
- [ ] Tier 1 rendered as full-width feature cards
- [ ] Tier 2 rendered as medium full-width cards
- [ ] Tier 3 rendered as compact cards, using two columns only when logos remain readable
- [ ] Use card size, logo area, spacing, and tier heading—not color alone—to communicate rank accessibly.
- [ ] Keep `partner.tier` and `partner.sortOrder` as the CMS source of truth.
Approval gate:
- [ ] Produce desktop, tablet, and mobile mockups using the current partner counts.
- [ ] Review the hierarchy with the client before implementing the final grid.
- [ ] After approval, implement the chosen breakpoints without changing the partner data model unless the mockup reveals a real need.
Acceptance criteria:
- [ ] Hierarchy is immediately understandable at all three breakpoint classes.
- [ ] Adding or removing a partner does not create broken gaps or stretch a card unpredictably.
- [ ] Logo aspect ratios remain intact.
### 9. Make sponsor ordering explicit and manageable
- [ ] Confirm that `sortOrder` remains authoritative within each tier.
- [ ] Show `tier`, `sortOrder`, publication status, and active state in useful Partners admin columns.
- [ ] Add validation preventing unsupported tier values outside 1–3.
- [ ] Document the current order for client review:
1. RSM Ebner Stolz
2. WWK
3. Radio Gong 96.3
4. münchen.tv
5. METZLER
6. Dr. Wieselhuber & Partner
7. Fristads
8. New Edge
9. Moodtalk
10. Deutsche Bank
11. Bionorica
12. Primus
- [ ] Treat an order change as a CMS content operation, not a frontend code change.
### 10. Slow the homepage sponsor ticker
- [ ] Replace the fixed `22s` duration with a slower content-aware duration or an agreed slower fixed baseline.
- [ ] Keep perceived speed stable as the number of logos changes.
- [ ] Pause or substantially reduce motion on hover/focus and respect `prefers-reduced-motion`.
- [ ] Verify that duplicating the track still produces a seamless loop.
Acceptance criteria:
- [ ] The ticker feels calm enough to read individual logos.
- [ ] No jump is visible at the loop boundary.
### 11. Correct the homepage phase-heading line break
- [ ] Confirm the client copy belongs to the homepage status/phase module.
- [ ] Make phase headlines honor explicit CMS newlines.
- [ ] Store/render the target split as:
```text
Ein weiteres
erfolgreiches Jahr
```
- [ ] Preserve responsive wrapping when translated or edited copy is longer.
- [ ] Do not hard-code this one sentence in the component.
### 12. Adjust the homepage bottom application/newsletter split
- [ ] Widen the blue left panel on the homepage so the boundary with the gold form panel feels more balanced.
- [ ] Scope the ratio change through the existing `source="homepage"` prop unless the client separately approves the same change on `/teilnahme`.
- [ ] Recheck text measure, benefit rows, form width, and the image area after changing the ratio.
- [ ] Verify both open-application and newsletter phase variants.
### 13. Verify the shared application/newsletter background color
The current component already uses dark blue `#24366A` instead of black.
- [ ] Compare the deployed site with the current source.
- [ ] If production is still black, deploy the existing dark-blue implementation or identify the production content/style override.
- [ ] Make no additional palette change if production already matches the requested dark blue.
Verification record (2026-07-31): The canonical production deployment at
`https://srv1.bayerischer-mittelstandspreis.de` was checked read-only on `/` and
`/teilnahme`. Both routes were serving the newsletter variant, and the rendered
`#bewerben` section computed to `rgb(36, 54, 106)` (`#24366A`), with no black or
content/style override. The shared source owner is
`src/spa/components/forms/ApplicationFormSection.tsx`; its unconditional outer
section background applies to both the application-open and newsletter variants.
No palette or deployment change was needed.
### 14. Improve `/teilnahme` eligibility hierarchy
- [ ] Keep the existing content and CTAs.
- [ ] Introduce clearer grouping within the white eligibility area through restrained section tinting, dividers, spacing, or row containers.
- [ ] Avoid turning every criterion into an unrelated floating card.
- [ ] Make the visual transition into “Wichtige Termine” clear without redesigning the rest of the page.
- [ ] Verify mobile scan order and contrast.
### 15. Adjust `/der-bmp` “Relevanz & Zielsetzung”
- [ ] Increase the desktop share of the left image so more of it is visible.
- [ ] Shift the right-hand copy farther into the viewport and give it a centered content measure within its panel.
- [ ] Tune the image `object-position` rather than cropping important subjects.
- [ ] Keep mobile image-first ordering and readable padding.
### 16. Correct `/der-bmp` “Was ist der Preis?” facts and caption
- [ ] Center the three key facts within the text panel instead of anchoring each value to the left edge.
- [ ] Keep separators and equal visual weight across all three facts.
- [ ] Improve the image-caption legibility with a controlled background/overlay or by moving it into a dedicated caption area.
- [ ] Verify long CMS labels do not collide.
### 17. Fix sitemap route generation
Sitemap support already exists, but current output needs correction.
- [ ] Generate the homepage URL as `/` when the page slug is either `home` or `startseite`, or prefer the stored `spaPath` directly.
- [ ] Generate press article URLs under `/presse/blog/<slug>` instead of `/posts/<slug>`.
- [ ] Add event detail URLs under `/presse/events/<slug>`.
- [ ] Add winner detail URLs under `/preistraeger/<slug>`.
- [ ] Keep unpublished documents out by using public access and `_status = published`.
- [ ] Ensure generated sitemap and robots URLs use the production base URL during deployment, never committed localhost/example URLs.
- [ ] Add route-level tests for root, page, event, article, and winner entries.
Acceptance criteria:
- [ ] Every sitemap URL returns `200` on production.
- [ ] Unpublished entries disappear from the sitemap immediately or after the documented cache/revalidation window.
### 18. Regression, migration, and deployment verification
- [ ] Run focused unit/integration tests for publication filtering, relationship resolution, and sitemap paths.
- [ ] Run TypeScript without incremental cache.
- [ ] Run the relevant Vitest suite.
- [ ] Build against a migrated copy of `content.db`.
- [ ] Visually verify at approximately `1440px`, `1024px`, `768px`, and `390px` widths.
- [ ] Test anonymous public browsing separately from authenticated draft preview.
- [ ] Back up the production SQLite database.
- [ ] Deploy the schema-changing commit with explicit migrations enabled.
- [ ] Upload/select the client-supplied Netzwerk video and the selected press image in production Payload.
- [ ] Smoke-test all affected routes after deployment:
- [ ] `/`
- [ ] `/teilnahme`
- [ ] `/der-bmp`
- [ ] `/netzwerk`
- [ ] `/presse`
- [ ] representative event/article/winner detail routes
- [ ] sitemap and robots endpoints
- [ ] Perform a production unpublish/republish smoke test using disposable or approved test records.
## Feedback traceability
| Workbook feedback | Planned work |
| --- | --- |
| CMS unpublish button does not work | Tasks 1–3 and 18 |
| `/teilnahme` steps 2–4 do not open; excess space | Task 7 |
| Separate Ilse Aigner video from homepage | Task 5 |
| Remove press-kit CMS controls | Task 4 |
| Keep Pages focused and shared content in Globals | Task 4 |
| Remove accreditation form copy | Task 4 |
| Shared 2027-interest section should be dark blue | Task 13; current source already appears compliant |
| `/der-bmp` relevance text/image alignment | Task 15 |
| Partner tier hierarchy and responsive layout | Tasks 8–9 |
| Add image beside press contact | Task 6 |
| Add/fix sitemap in code | Task 17 |
| Sponsor ticker feels too fast | Task 10 |
| Homepage success heading line break | Task 11 |
| Homepage bottom blue panel should be wider | Task 12 |
| Participation eligibility area lacks structure | Task 14 |
| `/der-bmp` facts and caption are misaligned/hard to read | Task 16 |
| Confirm sponsor order | Task 9 |
| “Aktuelles” looks good | No change required |

View File

@@ -0,0 +1,73 @@
# Payload field-to-consumer inventory
This inventory records the frontend or platform owner for every retained `Pages` field group and relevant Global. Nested scalar, upload, relationship, and array fields belong to the consumer named for their parent group unless an exception is listed.
## Shared `Pages` fields
| Field path | Consumer or contract |
| --- | --- |
| `title`, `slug`, `spaPath`, `publishedAt`, `_status` | Route lookup, preview URLs, publication access, sitemap generation, and generic-page headings in `src/app/(frontend)/[[...slug]]/page.tsx`, the sitemap routes, and `src/spa/pages/CmsGenericPage.tsx`. |
| `hero`, `layout` | Non-SPA pages through `src/heros/RenderHero.tsx`, `src/blocks/RenderBlocks.tsx`, and `src/spa/pages/CmsGenericPage.tsx`. |
| `meta.*` | `@payloadcms/plugin-seo`, route metadata generation, and generic-page title fallback. |
## Page-specific fields
| Page field path | Retained consumer |
| --- | --- |
| `home.hero.*` | `src/spa/pages/Home.tsx`; partner relationship records remain authoritative and the names array is presentation fallback only. |
| `home.winners.*` | `src/spa/components/HomeWinnersSection.tsx` and `src/spa/homeWinnerSelection.ts`; also reused by `/der-bmp`. |
| `home.quickCheck.*`, `home.intro.*`, `home.benefits.*` | `src/spa/pages/Home.tsx`. |
| `home.testimonials.*` | `src/spa/pages/Home.tsx` through `src/spa/components/TestimonialsSection.tsx`. |
| `home.videoModal.*` | Homepage video action and modal in `src/spa/pages/Home.tsx`. |
| `participation.hero.*`, `participation.eligibility.*`, `participation.applicationWays.*`, `participation.datesBanner.*` | `src/spa/pages/Participation.tsx`. |
| `participation.process.*` | `src/spa/pages/Participation.tsx` through `src/spa/components/WegZurAuszeichnungSection.tsx`. |
| `participation.awardsGrid.*` | `src/spa/pages/Participation.tsx` through `src/spa/components/AwardsGridSection.tsx`. |
| `about.hero.*`, `about.prize.*`, `about.mittelstand.*`, `about.history.*`, `about.goals.*`, `about.values.*`, `about.cta.*` | `src/spa/pages/About.tsx`. |
| `about.testimonials.*` | `src/spa/pages/About.tsx` through `src/spa/components/TestimonialsSection.tsx`. |
| `contact.*` | `src/spa/pages/Contact.tsx` and `src/spa/components/forms/KontaktForm.tsx`. |
| `datenschutz.*`, `impressum.*` | `src/spa/components/legal/LegalPage.tsx` via the respective route component. |
| `formUpload.*` | `src/spa/pages/FormularUpload.tsx`. |
| `mitgliedWerden.*` | `src/spa/pages/MitgliedWerden.tsx` and `src/spa/components/membership/MembershipWizard.tsx`. |
| `preistraegerIndex.*` | `src/spa/pages/Preistraeger.tsx`; winner records remain collection-owned. |
| `pressIndex.hero.*`, `pressIndex.events.*`, `pressIndex.news.*`, `pressIndex.downloads.*` | `src/spa/pages/Press.tsx`; event/article records remain collection-owned, while missing-field labels and images are presentation fallbacks only. `pressIndex.downloads.contactImage` and `contactImageAlt` own the optional Presse contact visual; an empty relationship renders the same copy in a compact copy-only layout without a static image fallback. |
| `netzwerk.hero.*`, `netzwerk.patronage.*`, `netzwerk.juryIntro.*`, `netzwerk.expertise.*`, `netzwerk.sponsoring.*` | `src/spa/pages/Netzwerk.tsx`. The page-owned `netzwerk.patronage.greetingVideo` Media relationship and its adjacent button/modal copy control only the Ilse Aigner greeting on `/netzwerk`; they never inherit `home.videoModal.*`. |
| `netzwerk.jury.{eyebrow,heading,description,chairBadge,members}` | Jury heading and authoritative rendered relationship selection in `src/spa/pages/Netzwerk.tsx`. |
| `netzwerk.partners.{eyebrow,heading,description,detailLabel,tiers[].{label,note},modal}` | All visible sponsor-section editorial copy, including each rendered tier heading/note and the shared card CTA, in `src/spa/pages/Netzwerk.tsx`; partner names, descriptions, links, logos, tier assignment, and ordering remain Partner collection-owned. Counts are derived and are not separately editable. |
| `netzwerk.sponsoringForm.*` | The two visible form steps, validation, success state, and footer copy in `src/spa/components/forms/SponsoringForm.tsx`. |
## Partner collection administration
The Partners list defaults to `name`, `tier`, `sortOrder`, `_status`, `active`, `showOnHomepage`, and `updatedAt`, so editors can review presentation order and visibility without opening every record. `tier` accepts only the numeric values 1, 2, and 3. Within each tier, lower `sortOrder` values render first; tiers remain separate presentation bands.
The current published local CMS data was verified on 2026-07-31 in this client-review order:
| Review order | Partner | Tier | `sortOrder` |
| ---: | --- | ---: | ---: |
| 1 | RSM Ebner Stolz | 1 | 1 |
| 2 | WWK | 1 | 2 |
| 3 | Radio Gong 96.3 | 2 | 3 |
| 4 | münchen.tv | 2 | 4 |
| 5 | METZLER | 3 | 5 |
| 6 | Dr. Wieselhuber & Partner | 3 | 6 |
| 7 | Fristads | 3 | 7 |
| 8 | New Edge | 3 | 8 |
| 9 | Moodtalk | 3 | 9 |
| 10 | Deutsche Bank | 3 | 10 |
| 11 | Bionorica | 3 | 11 |
| 12 | Primus | 3 | 12 |
Future sponsor order changes are CMS content operations: edit the relevant Partner records' `tier` and `sortOrder` values. They do not require frontend code changes.
## Global ownership
| Global | Consumer or contract |
| --- | --- |
| `application-form` | Single source for the application section and form on both `/` and `/teilnahme`, hydrated by the frontend route and consumed by `ApplicationFormSection` / `BewerbungsForm`. |
| `application-phase` | Shared phase state and phase-specific site copy used by Home, shared layout CTAs, the application/newsletter form switch, and sponsoring submissions. |
| `newsletter-form` | Evaluation/completed newsletter variants shared by `/` and `/teilnahme`. |
| `header`, `footer` | Site-wide navigation, branding, links, social entries, and footer partner marks rendered by `src/spa/components/layout/Layout.tsx`. |
| `site-settings` | Shared skyline, Preisträger placeholder, and generic-page fallback copy. |
## Task 4 removals
The audit found no frontend, metadata, SEO, seed-only contract, or explicit Global purpose for these fields: `home.application`, `home.form`, `participation.applicationForm`, `participation.form`, the hidden participation CTA controls (`eligibility.secondaryCta`, `eligibility.nudgeCta`, `applicationWays.recommendedLabel`, `applicationWays.fallbackCta`, `applicationWays.ways[].featured`, and `applicationWays.ways[].cta`), `about.highlights`, the five `pressIndex.downloads.kit*` controls, `pressIndex.accreditation`, the old event/news CTA labels, `netzwerk.jury.note`, `netzwerk.partners.premiumLabel`, `netzwerk.partners.defaultLogoColor`, the four non-rendered `netzwerk.sponsoring` footnote/form-eyebrow controls, and the obsolete sponsoring package step (`packages`, `step1Eyebrow`, `step1Heading`, `requiredPackageError`). They are intentionally absent from the retained inventory.

View File

@@ -1,13 +1,12 @@
const SITE_URL =
process.env.NEXT_PUBLIC_SERVER_URL ||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
'https://example.com'
const { getSitemapBaseURL } = require('./src/utilities/sitemapBaseURL.cjs')
const SITE_URL = getSitemapBaseURL({ ...process.env, NODE_ENV: 'production' })
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: SITE_URL,
generateRobotsTxt: true,
exclude: ['/posts-sitemap.xml', '/pages-sitemap.xml', '/*', '/posts/*'],
exclude: ['/posts-sitemap.xml', '/pages-sitemap.xml', '/*'],
robotsTxtOptions: {
policies: [
{

View File

@@ -11,7 +11,7 @@ APP_DOMAIN="${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}"
SSH_TARGET="$SSH_USER@$VPS_HOST"
COMPOSE_FILE="$APP_ROOT/current/deploy/vps/docker-compose.yml"
ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
ssh -t -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
set -e
backup_dir='$APP_ROOT/backups'
backup_file=\"\$backup_dir/content-\$(date +%Y%m%d-%H%M%S).db\"

View File

@@ -26,7 +26,7 @@ const phaseFields: Field[] = [
text('tag', 'Badge'),
{ name: 'pulse', label: 'Show pulse dot', type: 'checkbox', defaultValue: true },
text('phase', 'Phase label'),
text('headline', 'Headline'),
textarea('headline', 'Headline'),
textarea('body', 'Body copy'),
{ name: 'cta', label: 'Primary CTA', type: 'group', fields: linkGroup() },
text('accent', 'Accent color'),

View File

@@ -3,28 +3,22 @@ import { getPayload } from 'payload'
import config from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPublicPublishedQueryOptions } from '@/utilities/publicationQuery'
import { createSitemapEntry, pageSitemapPath, type SitemapEntry } from '@/utilities/sitemapRoutes'
const getPagesSitemap = unstable_cache(
async () => {
const payload = await getPayload({ config })
const SITE_URL =
process.env.NEXT_PUBLIC_SERVER_URL ||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
'https://example.com'
const results = await payload.find({
collection: 'pages',
overrideAccess: false,
draft: false,
...getPublicPublishedQueryOptions(),
depth: 0,
limit: 1000,
pagination: false,
where: {
_status: {
equals: 'published',
},
},
select: {
slug: true,
spaPath: true,
updatedAt: true,
},
})
@@ -32,15 +26,8 @@ const getPagesSitemap = unstable_cache(
const dateFallback = new Date().toISOString()
const sitemap = results.docs
? results.docs
.filter((page) => Boolean(page?.slug))
.map((page) => {
return {
loc: page?.slug === 'home' ? `${SITE_URL}/` : `${SITE_URL}/${page?.slug}`,
lastmod: page.updatedAt || dateFallback,
}
})
: []
.map((page) => createSitemapEntry(page, pageSitemapPath(page), dateFallback))
.filter((entry): entry is SitemapEntry => Boolean(entry))
return sitemap
},

View File

@@ -3,44 +3,55 @@ import { getPayload } from 'payload'
import config from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPublicPublishedQueryOptions } from '@/utilities/publicationQuery'
import { createSitemapEntry, detailSitemapPath, type SitemapEntry } from '@/utilities/sitemapRoutes'
const sitemapCollections = [
{ collection: 'posts', prefix: '/presse/blog' },
{ collection: 'events', prefix: '/presse/events' },
{ collection: 'preistraeger', prefix: '/preistraeger' },
] as const
const getPostsSitemap = unstable_cache(
async () => {
const payload = await getPayload({ config })
const SITE_URL =
process.env.NEXT_PUBLIC_SERVER_URL ||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
'https://example.com'
const results = await payload.find({
collection: 'posts',
overrideAccess: false,
draft: false,
depth: 0,
limit: 1000,
pagination: false,
where: {
_status: {
equals: 'published',
},
},
select: {
slug: true,
updatedAt: true,
},
})
const results = await Promise.all(
sitemapCollections.map(({ collection }) =>
payload.find({
collection,
...getPublicPublishedQueryOptions(),
depth: 0,
limit: 1000,
pagination: false,
select: {
slug: true,
spaPath: true,
updatedAt: true,
},
}),
),
)
const dateFallback = new Date().toISOString()
const sitemap = results.docs
? results.docs
.filter((post) => Boolean(post?.slug))
.map((post) => ({
loc: `${SITE_URL}/posts/${post?.slug}`,
lastmod: post.updatedAt || dateFallback,
}))
: []
return results.flatMap((result, index) => {
const { prefix } = sitemapCollections[index]
return sitemap
return result.docs.reduce<SitemapEntry[]>((entries, document) => {
const entry = createSitemapEntry(
document,
detailSitemapPath(document, prefix),
dateFallback,
)
if (entry) {
entries.push(entry)
}
return entries
}, [])
})
},
['posts-sitemap'],
{

View File

@@ -11,6 +11,7 @@ import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation'
import type { CmsRouteCollection, CmsRouteDoc } from '@/spa/cmsRoute'
import { normalizeNewsletterFormData, type SpaNewsletterFormData } from '@/spa/newsletterForm'
import { normalizeSiteSettings, type SpaSiteSettingsData } from '@/spa/siteSettings'
import { getPublicationQueryOptions } from '@/utilities/publicationQuery'
export const dynamic = 'force-dynamic'
@@ -43,8 +44,7 @@ async function findRouteDoc(payload: Awaited<ReturnType<typeof getPayload>>, pat
const result = await payload.find({
collection: target.collection,
depth: 1,
draft,
overrideAccess: draft,
...getPublicationQueryOptions(draft),
limit: 1,
pagination: false,
where: {
@@ -63,8 +63,7 @@ async function findList(payload: Awaited<ReturnType<typeof getPayload>>, collect
const result = await payload.find({
collection,
depth: 1,
draft,
overrideAccess: draft,
...getPublicationQueryOptions(draft),
limit: 300,
pagination: false,
...(collection === 'partners' || collection === 'jury-mitglieder' ? { sort: 'sortOrder' } : {}),
@@ -77,8 +76,7 @@ async function findSupportPages(payload: Awaited<ReturnType<typeof getPayload>>,
const result = await payload.find({
collection: 'pages',
depth: 1,
draft,
overrideAccess: draft,
...getPublicationQueryOptions(draft),
limit: 10,
pagination: false,
where: {

View File

@@ -7,6 +7,8 @@ import { eventDetailContent } from '@/spa/eventDetailContent'
import { generatePreviewPath } from '@/utilities/generatePreviewPath'
import { slugField } from 'payload'
import { revalidateEvent, revalidateEventDelete } from './Events/hooks/revalidateEvent'
import {
MetaDescriptionField,
MetaTitleField,
@@ -273,6 +275,8 @@ export const Events: CollectionConfig = {
slugField(),
],
hooks: {
afterChange: [revalidateEvent],
afterDelete: [revalidateEventDelete],
beforeChange: [populatePublishedAt],
},
versions: {

View File

@@ -0,0 +1,46 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath, revalidateTag } from 'next/cache'
import type { Event } from '../../../payload-types'
const eventPath = (doc: Pick<Event, 'slug' | 'spaPath'>) =>
doc.spaPath || `/presse/events/${doc.slug}`
export const revalidateEvent: CollectionAfterChangeHook<Event> = ({
doc,
previousDoc,
req: { payload, context },
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const path = eventPath(doc)
payload.logger.info(`Revalidating event at path: ${path}`)
revalidatePath(path)
revalidateTag('posts-sitemap', 'max')
}
if (previousDoc?._status === 'published' && doc._status !== 'published') {
const oldPath = eventPath(previousDoc)
payload.logger.info(`Revalidating old event at path: ${oldPath}`)
revalidatePath(oldPath)
revalidateTag('posts-sitemap', 'max')
}
}
return doc
}
export const revalidateEventDelete: CollectionAfterDeleteHook<Event> = ({
doc,
req: { context },
}) => {
if (!context.disableRevalidate) {
revalidatePath(eventPath(doc))
revalidateTag('posts-sitemap', 'max')
}
return doc
}

View File

@@ -100,7 +100,7 @@ export const aboutFields: Field[] = [
defaultValue: aboutContent.prize.facts,
fields: statFields,
},
text('imageLabel', 'Desktop image label', aboutContent.prize.imageLabel),
text('imageLabel', 'Image caption', aboutContent.prize.imageLabel),
],
},
{
@@ -123,52 +123,9 @@ export const aboutFields: Field[] = [
},
],
},
{
name: 'highlights',
label: '04 · Preisträger highlights',
type: 'group',
admin: {
...sectionAdmin('Legacy data retained for compatibility. The rendered section now reuses the homepage Preisträger card grid.'),
hidden: true,
},
fields: [
uploadField('fallbackImage', 'Fallback winner image', `Current frontend fallback image: /images/${aboutContent.highlights.fallbackImageFilename}`),
text('eyebrow', 'Eyebrow', aboutContent.highlights.eyebrow),
textarea('heading', 'Heading', aboutContent.highlights.heading),
ctaGroup('cta', 'CTA', aboutContent.highlights.cta.label, aboutContent.highlights.cta.url),
text('hoverLabel', 'Card hover label', aboutContent.highlights.hoverLabel),
{
name: 'fallbackWinner',
label: 'Fallback winner text',
type: 'group',
fields: [
text('name', 'Name fallback', aboutContent.highlights.fallbackWinner.name),
text('category', 'Category fallback', aboutContent.highlights.fallbackWinner.category),
text('awardType', 'Award type fallback', aboutContent.highlights.fallbackWinner.awardType),
],
},
{
name: 'year',
label: 'Fallback winner year',
type: 'number',
defaultValue: aboutContent.highlights.year,
},
{
name: 'selectedWinners',
label: 'Selected winners',
type: 'relationship',
relationTo: 'preistraeger',
hasMany: true,
maxRows: 8,
admin: {
description: 'Choose up to 8 Preisträger to show. If empty, the frontend falls back to winners from the selected year.',
},
},
],
},
{
name: 'testimonials',
label: '05 · Testimonials',
label: '04 · Testimonials',
type: 'group',
admin: sectionAdmin('Testimonial heading and slides. Upload images when available, or keep the migrated URL fallback.'),
fields: [
@@ -199,7 +156,7 @@ export const aboutFields: Field[] = [
},
{
name: 'history',
label: '06 · Geschichte & Meilensteine',
label: '05 · Geschichte & Meilensteine',
type: 'group',
admin: sectionAdmin('Navy timeline section.'),
fields: [
@@ -219,7 +176,7 @@ export const aboutFields: Field[] = [
},
{
name: 'goals',
label: '07 · Zielsetzung',
label: '06 · Zielsetzung',
type: 'group',
admin: sectionAdmin('Cream values list next to the history timeline.'),
fields: [
@@ -237,7 +194,7 @@ export const aboutFields: Field[] = [
},
{
name: 'values',
label: '08 · Vorteile & Werte',
label: '07 · Vorteile & Werte',
type: 'group',
admin: sectionAdmin('Cream editorial rows before the final CTA.'),
fields: [
@@ -256,7 +213,7 @@ export const aboutFields: Field[] = [
},
{
name: 'cta',
label: '09 · Final CTA',
label: '08 · Final CTA',
type: 'group',
admin: sectionAdmin('Bottom navy CTA section.'),
fields: [

View File

@@ -33,33 +33,6 @@ const sectionAdmin = (description: string) => ({
initCollapsed: true,
})
const iconOptions = [
{ label: 'Alert circle', value: 'alertCircle' },
{ label: 'Arrow right', value: 'arrowRight' },
{ label: 'Building', value: 'building2' },
{ label: 'Check', value: 'check' },
{ label: 'Check circle', value: 'checkCircle2' },
{ label: 'File text', value: 'fileText' },
{ label: 'Map pin', value: 'mapPin' },
{ label: 'Search', value: 'search' },
{ label: 'Send', value: 'send' },
{ label: 'Star', value: 'star' },
{ label: 'Trending up', value: 'trendingUp' },
{ label: 'Trophy', value: 'trophy' },
{ label: 'User', value: 'user' },
{ label: 'User check', value: 'userCheck' },
{ label: 'User plus', value: 'userPlus' },
{ label: 'Users', value: 'users' },
]
const iconField = (defaultValue = 'star'): Field => ({
name: 'icon',
label: 'Icon',
type: 'select',
defaultValue,
options: iconOptions,
})
export const homeFields: Field[] = [
{
name: 'home',
@@ -219,144 +192,9 @@ export const homeFields: Field[] = [
},
],
},
{
name: 'application',
label: '07 · Application form section',
type: 'group',
admin: { ...sectionAdmin('Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
fields: [
text('eyebrow', 'Eyebrow', homeContent.application.eyebrow),
textarea('heading', 'Heading', homeContent.application.heading),
textarea('body', 'Body copy', homeContent.application.body),
{ name: 'benefits', label: 'Benefit rows', type: 'array', defaultValue: homeContent.application.benefits, fields: [text('num', 'Number'), text('label', 'Label'), text('desc', 'Description')] },
uploadField('awardImage', 'Award image', `Current frontend image: /images/${homeContent.application.awardImageFilename}`),
text('awardImageAlt', 'Award image alt text', homeContent.application.awardImageAlt),
text('awardCaption', 'Award image caption', homeContent.application.awardCaption),
text('formEyebrow', 'Form eyebrow', homeContent.application.formEyebrow),
text('formTitle', 'Form title', homeContent.application.formTitle),
text('footnote', 'Footnote normal text', homeContent.application.footnote),
text('footnoteStrong', 'Footnote highlighted text', homeContent.application.footnoteStrong),
],
},
{
name: 'form',
label: '08 · Embedded application form copy and logic labels',
type: 'group',
admin: { ...sectionAdmin('Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
fields: [
text('stepCompleteLabel', 'Completed step label', homeContent.form.stepCompleteLabel),
text('backLabel', 'Back button label', homeContent.form.backLabel),
text('nextLabel', 'Next button label', homeContent.form.nextLabel),
text('submitLabel', 'Submit button label', homeContent.form.submitLabel),
text('yesLabel', 'Yes option label', homeContent.form.yesLabel),
text('noLabel', 'No option label', homeContent.form.noLabel),
text('requiredSuffix', 'Required field suffix', homeContent.form.requiredSuffix),
{
name: 'validation',
label: 'Validation messages',
type: 'group',
fields: [
text('choose', 'Choose message', homeContent.form.validation.choose),
text('required', 'Required message', homeContent.form.validation.required),
text('invalidEmail', 'Invalid email message', homeContent.form.validation.invalidEmail),
],
},
{
name: 'typeStep',
label: 'Step 1 · Submission type',
type: 'group',
fields: [
text('eyebrow', 'Eyebrow', homeContent.form.typeStep.eyebrow),
text('heading', 'Heading', homeContent.form.typeStep.heading),
text('selfTitle', 'Self-application title', homeContent.form.typeStep.selfTitle),
textarea('selfDesc', 'Self-application description', homeContent.form.typeStep.selfDesc),
text('nominationTitle', 'Nomination title', homeContent.form.typeStep.nominationTitle),
textarea('nominationDesc', 'Nomination description', homeContent.form.typeStep.nominationDesc),
],
},
{
name: 'selfSteps',
label: 'Self-application step labels',
type: 'array',
dbName: 'home_self_steps',
defaultValue: homeContent.form.selfSteps,
fields: [text('label', 'Label'), iconField('trophy')],
},
{
name: 'nominationSteps',
label: 'Nomination step labels',
type: 'array',
dbName: 'home_nom_steps',
defaultValue: homeContent.form.nominationSteps,
fields: [text('label', 'Label'), iconField('trophy')],
},
{
name: 'employeeOptions',
label: 'Employee options',
type: 'array',
dbName: 'home_emp_opts',
defaultValue: homeContent.form.employeeOptions,
fields: [text('value', 'Value'), text('label', 'Label')],
},
{
name: 'eligibility',
label: 'Self-application eligibility step',
type: 'group',
fields: [
text('allowedEmployeeValues', 'Allowed employee option values, comma-separated', homeContent.form.eligibility.allowedEmployeeValues),
text('requiredBayernValue', 'Required Bavaria answer value', homeContent.form.eligibility.requiredBayernValue),
text('eyebrow', 'Eyebrow', homeContent.form.eligibility.eyebrow),
text('heading', 'Heading', homeContent.form.eligibility.heading),
text('employeesLabel', 'Employees field label', homeContent.form.eligibility.employeesLabel),
text('bayernLabel', 'Bavaria field label', homeContent.form.eligibility.bayernLabel),
text('ownerLabel', 'Owner field label', homeContent.form.eligibility.ownerLabel),
text('ineligibleHeading', 'Ineligible heading', homeContent.form.eligibility.ineligibleHeading),
textarea('ineligibleBody', 'Ineligible body', homeContent.form.eligibility.ineligibleBody),
text('ineligibleContactPrefix', 'Ineligible contact prefix', homeContent.form.eligibility.ineligibleContactPrefix),
text('ineligibleContactEmail', 'Ineligible contact email', homeContent.form.eligibility.ineligibleContactEmail),
],
},
{
name: 'selfContact',
label: 'Self-application contact step',
type: 'group',
fields: Object.entries(homeContent.form.selfContact).map(([name, value]) => text(name, name, value)),
},
{
name: 'selfSummary',
label: 'Self-application summary step',
type: 'group',
fields: Object.entries(homeContent.form.selfSummary).map(([name, value]) => text(name, name, value)),
},
{
name: 'nominationCompany',
label: 'Nomination company step',
type: 'group',
fields: Object.entries(homeContent.form.nominationCompany).map(([name, value]) => text(name, name, value)),
},
{
name: 'nominationContact',
label: 'Nomination contact step',
type: 'group',
fields: Object.entries(homeContent.form.nominationContact).map(([name, value]) => text(name, name, value)),
},
{
name: 'nominationSummary',
label: 'Nomination summary step',
type: 'group',
fields: Object.entries(homeContent.form.nominationSummary).map(([name, value]) => text(name, name, value)),
},
{
name: 'success',
label: 'Success state copy',
type: 'group',
fields: Object.entries(homeContent.form.success).map(([name, value]) => text(name, name, value)),
},
],
},
{
name: 'videoModal',
label: '10 · Video modal',
label: '07 · Video modal',
type: 'group',
admin: sectionAdmin('Video and fallback copy shown when the hero video button is clicked.'),
fields: [

View File

@@ -11,7 +11,8 @@ export const revalidatePage: CollectionAfterChangeHook<Page> = ({
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const path = doc.slug === 'home' ? '/' : `/${doc.slug}`
const path =
doc.spaPath || (doc.slug === 'home' || doc.slug === 'startseite' ? '/' : `/${doc.slug}`)
payload.logger.info(`Revalidating page at path: ${path}`)
@@ -21,7 +22,11 @@ export const revalidatePage: CollectionAfterChangeHook<Page> = ({
// If the page was previously published, we need to revalidate the old path
if (previousDoc?._status === 'published' && doc._status !== 'published') {
const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}`
const oldPath =
previousDoc.spaPath ||
(previousDoc.slug === 'home' || previousDoc.slug === 'startseite'
? '/'
: `/${previousDoc.slug}`)
payload.logger.info(`Revalidating old page at path: ${oldPath}`)
@@ -34,7 +39,8 @@ export const revalidatePage: CollectionAfterChangeHook<Page> = ({
export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {
if (!context.disableRevalidate) {
const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}`
const path =
doc?.spaPath || (doc?.slug === 'home' || doc?.slug === 'startseite' ? '/' : `/${doc?.slug}`)
revalidatePath(path)
revalidateTag('pages-sitemap', 'max')
}

View File

@@ -51,7 +51,6 @@ const personFields: Field[] = [
const rowFields: Field[] = [text('num', 'Number'), text('label', 'Label'), textarea('body', 'Body')]
const benefitFields: Field[] = [text('num', 'Number'), text('label', 'Label'), text('sub', 'Subline')]
const packageFields: Field[] = [text('value', 'Value'), text('title', 'Title'), textarea('desc', 'Description')]
const formStepFields: Field[] = [text('label', 'Label')]
export const netzwerkFields: Field[] = [
@@ -101,6 +100,24 @@ export const netzwerkFields: Field[] = [
text('greetingTitleLine2', 'Greeting title line 2', netzwerkContent.patronage.greetingTitleLine2),
textarea('greetingQuote', 'Greeting quote', netzwerkContent.patronage.greetingQuote),
text('greetingAttribution', 'Greeting attribution', netzwerkContent.patronage.greetingAttribution),
{
name: 'greetingVideo',
type: 'upload',
relationTo: 'media',
label: 'Greeting video',
filterOptions: {
mimeType: {
contains: 'video',
},
},
admin: {
description: 'Choose the Ilse Aigner greeting video. If empty, the video action is hidden.',
},
},
text('greetingVideoButtonLabel', 'Video button label', netzwerkContent.patronage.greetingVideoButtonLabel),
text('greetingVideoTitle', 'Video title', netzwerkContent.patronage.greetingVideoTitle),
text('greetingVideoDescription', 'Video description', netzwerkContent.patronage.greetingVideoDescription),
text('greetingVideoCloseLabel', 'Video close button label', netzwerkContent.patronage.greetingVideoCloseLabel),
],
},
{
@@ -134,13 +151,12 @@ export const netzwerkFields: Field[] = [
name: 'jury',
label: '04 · Jury members',
type: 'group',
admin: sectionAdmin('Jury section heading, chair badge, members, and note.'),
admin: sectionAdmin('Jury section heading, chair badge, and rendered member selection.'),
fields: [
text('eyebrow', 'Eyebrow', netzwerkContent.jury.eyebrow),
textarea('heading', 'Heading', netzwerkContent.jury.heading),
textarea('description', 'Description', netzwerkContent.jury.description),
text('chairBadge', 'Chair badge', netzwerkContent.jury.chairBadge),
textarea('note', 'Note', netzwerkContent.jury.note),
{
name: 'members',
label: 'Members',
@@ -183,14 +199,15 @@ export const netzwerkFields: Field[] = [
textarea('heading', 'Heading', netzwerkContent.partners.heading),
textarea('description', 'Description', netzwerkContent.partners.description),
text('detailLabel', 'Detail label', netzwerkContent.partners.detailLabel),
text('premiumLabel', 'Premium label', netzwerkContent.partners.premiumLabel),
text('defaultLogoColor', 'Default logo color', netzwerkContent.partners.defaultLogoColor),
{
name: 'tiers',
label: 'Tiers',
label: 'Tier headings and supporting copy',
type: 'array',
dbName: 'network_tiers',
defaultValue: netzwerkContent.partners.tiers,
admin: {
description: 'One copy row per sponsor tier. Partner membership and order come from each Partner record.',
},
fields: [numberField('tier', 'Tier'), text('label', 'Label'), text('note', 'Note')],
},
{
@@ -214,7 +231,7 @@ export const netzwerkFields: Field[] = [
name: 'sponsoring',
label: '07 · Sponsoring section',
type: 'group',
admin: sectionAdmin('Sponsoring pitch, image, benefits, form eyebrow, and membership footnote.'),
admin: sectionAdmin('Sponsoring pitch, image, benefits, and form eyebrow.'),
fields: [
text('eyebrow', 'Eyebrow', netzwerkContent.sponsoring.eyebrow),
textarea('heading', 'Heading', netzwerkContent.sponsoring.heading),
@@ -222,10 +239,6 @@ export const netzwerkFields: Field[] = [
uploadField('image', 'Form panel image', `Current frontend image: /images/${netzwerkContent.sponsoring.imageFilename}`),
text('imageAlt', 'Image alt text', netzwerkContent.sponsoring.imageAlt),
text('imageLabel', 'Image label', netzwerkContent.sponsoring.imageLabel),
text('formEyebrow', 'Form eyebrow', netzwerkContent.sponsoring.formEyebrow),
text('footnotePrefix', 'Footnote prefix', netzwerkContent.sponsoring.footnotePrefix),
text('footnoteLabel', 'Footnote link label', netzwerkContent.sponsoring.footnoteLabel),
text('footnoteUrl', 'Footnote link URL', netzwerkContent.sponsoring.footnoteUrl),
{
name: 'benefits',
label: 'Benefits',
@@ -240,7 +253,7 @@ export const netzwerkFields: Field[] = [
name: 'sponsoringForm',
label: '08 · Sponsoring form',
type: 'group',
admin: sectionAdmin('Wizard labels, package options, validation messages, success copy, and footer note.'),
admin: sectionAdmin('Two-step wizard labels, field copy, validation messages, success copy, and footer note.'),
fields: [
{
name: 'steps',
@@ -248,26 +261,18 @@ export const netzwerkFields: Field[] = [
type: 'array',
dbName: 'network_form_steps',
defaultValue: netzwerkContent.sponsoringForm.steps,
minRows: 2,
maxRows: 2,
fields: formStepFields,
},
{
name: 'packages',
label: 'Package options',
type: 'array',
dbName: 'network_form_pkgs',
defaultValue: netzwerkContent.sponsoringForm.packages,
fields: packageFields,
},
text('step1Eyebrow', 'Step 1 eyebrow', netzwerkContent.sponsoringForm.step1Eyebrow),
text('step1Heading', 'Step 1 heading', netzwerkContent.sponsoringForm.step1Heading),
text('step2Eyebrow', 'Step 2 eyebrow', netzwerkContent.sponsoringForm.step2Eyebrow),
text('step2Heading', 'Step 2 heading', netzwerkContent.sponsoringForm.step2Heading),
text('step2Eyebrow', 'Step 1 eyebrow', netzwerkContent.sponsoringForm.step2Eyebrow),
text('step2Heading', 'Step 1 heading', netzwerkContent.sponsoringForm.step2Heading),
text('companyLabel', 'Company label', netzwerkContent.sponsoringForm.companyLabel),
text('companyPlaceholder', 'Company placeholder', netzwerkContent.sponsoringForm.companyPlaceholder),
text('industryLabel', 'Industry label', netzwerkContent.sponsoringForm.industryLabel),
text('industryPlaceholder', 'Industry placeholder', netzwerkContent.sponsoringForm.industryPlaceholder),
text('step3Eyebrow', 'Step 3 eyebrow', netzwerkContent.sponsoringForm.step3Eyebrow),
text('step3Heading', 'Step 3 heading', netzwerkContent.sponsoringForm.step3Heading),
text('step3Eyebrow', 'Step 2 eyebrow', netzwerkContent.sponsoringForm.step3Eyebrow),
text('step3Heading', 'Step 2 heading', netzwerkContent.sponsoringForm.step3Heading),
text('contactLabel', 'Contact label', netzwerkContent.sponsoringForm.contactLabel),
text('contactPlaceholder', 'Contact placeholder', netzwerkContent.sponsoringForm.contactPlaceholder),
text('emailLabel', 'Email label', netzwerkContent.sponsoringForm.emailLabel),
@@ -275,7 +280,6 @@ export const netzwerkFields: Field[] = [
text('backLabel', 'Back label', netzwerkContent.sponsoringForm.backLabel),
text('nextLabel', 'Next label', netzwerkContent.sponsoringForm.nextLabel),
text('submitLabel', 'Submit label', netzwerkContent.sponsoringForm.submitLabel),
text('requiredPackageError', 'Required package error', netzwerkContent.sponsoringForm.requiredPackageError),
text('requiredFieldError', 'Required field error', netzwerkContent.sponsoringForm.requiredFieldError),
text('invalidEmailError', 'Invalid email error', netzwerkContent.sponsoringForm.invalidEmailError),
text('doneLabel', 'Done step label', netzwerkContent.sponsoringForm.doneLabel),

View File

@@ -24,13 +24,6 @@ const textarea = (name: string, label: string, defaultValue?: string): Field =>
defaultValue,
})
const checkbox = (name: string, label: string, defaultValue = false): Field => ({
name,
type: 'checkbox',
label,
defaultValue,
})
const sectionAdmin = (description: string) => ({
description,
initCollapsed: true,
@@ -133,7 +126,6 @@ export const participationFields: Field[] = [
textarea('heading', 'Heading', participationContent.eligibility.heading),
textarea('description', 'Intro paragraph', participationContent.eligibility.description),
ctaGroup('primaryCta', 'Primary CTA', participationContent.eligibility.primaryCta.label, participationContent.eligibility.primaryCta.url),
ctaGroup('secondaryCta', 'Secondary CTA', participationContent.eligibility.secondaryCta.label, participationContent.eligibility.secondaryCta.url),
{
name: 'criteria',
label: 'Eligibility rows',
@@ -151,7 +143,6 @@ export const participationFields: Field[] = [
fields: [text('title', 'Title'), textarea('body', 'Body'), iconField('building2')],
},
text('nudgeText', 'Bottom nudge text', participationContent.eligibility.nudgeText),
ctaGroup('nudgeCta', 'Bottom CTA', participationContent.eligibility.nudgeCta.label, participationContent.eligibility.nudgeCta.url),
],
},
{
@@ -163,13 +154,6 @@ export const participationFields: Field[] = [
text('eyebrow', 'Eyebrow', participationContent.applicationWays.eyebrow),
textarea('heading', 'Heading', participationContent.applicationWays.heading),
textarea('description', 'Intro paragraph', participationContent.applicationWays.description),
text('recommendedLabel', 'Featured card label', participationContent.applicationWays.recommendedLabel),
ctaGroup(
'fallbackCta',
'Fallback CTA for path cards',
participationContent.applicationWays.fallbackCta.label,
participationContent.applicationWays.fallbackCta.url,
),
{
name: 'institutions',
label: 'Proposal institutions',
@@ -189,7 +173,6 @@ export const participationFields: Field[] = [
textarea('title', 'Title'),
textarea('desc', 'Description'),
iconField('send', 'ico'),
checkbox('featured', 'Featured card'),
{
name: 'listType',
label: 'Detail list type',
@@ -208,7 +191,6 @@ export const participationFields: Field[] = [
dbName: 'way_facts',
fields: factFields,
},
ctaGroup('cta', 'CTA', participationContent.applicationWays.fallbackCta.label, participationContent.applicationWays.fallbackCta.url),
],
},
],
@@ -268,149 +250,6 @@ export const participationFields: Field[] = [
},
],
},
{
name: 'applicationForm',
label: '07 · Application form wrapper',
type: 'group',
admin: { ...sectionAdmin('Legacy page-level form section. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
fields: [
uploadField('image', 'Form image', `Current frontend image: /images/${participationContent.applicationForm.imageFilename}`),
text('imageAlt', 'Image alt text', participationContent.applicationForm.imageAlt),
text('imageCaption', 'Image caption', participationContent.applicationForm.imageCaption),
text('eyebrow', 'Eyebrow', participationContent.applicationForm.eyebrow),
textarea('heading', 'Heading', participationContent.applicationForm.heading),
textarea('description', 'Body copy', participationContent.applicationForm.description),
{
name: 'facts',
label: 'Fact rows',
type: 'array',
dbName: 'form_facts',
defaultValue: participationContent.applicationForm.facts,
fields: [text('num', 'Number'), text('label', 'Label'), textarea('desc', 'Description')],
},
text('formEyebrow', 'Form eyebrow', participationContent.applicationForm.formEyebrow),
text('formTitle', 'Form title', participationContent.applicationForm.formTitle),
ctaGroup('uploadCta', 'Top upload CTA', participationContent.applicationForm.uploadCta.label, participationContent.applicationForm.uploadCta.url),
textarea('uploadPrompt', 'Bottom upload prompt', participationContent.applicationForm.uploadPrompt),
ctaGroup('uploadFooterCta', 'Bottom upload CTA', participationContent.applicationForm.uploadFooterCta.label, participationContent.applicationForm.uploadFooterCta.url),
],
},
{
name: 'form',
label: '08 · Application form copy and logic labels',
type: 'group',
admin: { ...sectionAdmin('Legacy page-level form fields. The frontend now uses Formulare > Bewerbungsformular.'), hidden: true },
fields: [
text('stepCompleteLabel', 'Completed step label', participationContent.form.stepCompleteLabel),
text('backLabel', 'Back button label', participationContent.form.backLabel),
text('nextLabel', 'Next button label', participationContent.form.nextLabel),
text('submitLabel', 'Submit button label', participationContent.form.submitLabel),
text('yesLabel', 'Yes option label', participationContent.form.yesLabel),
text('noLabel', 'No option label', participationContent.form.noLabel),
text('requiredSuffix', 'Required field suffix', participationContent.form.requiredSuffix),
{
name: 'validation',
label: 'Validation messages',
type: 'group',
fields: [
text('choose', 'Choose message', participationContent.form.validation.choose),
text('required', 'Required message', participationContent.form.validation.required),
text('invalidEmail', 'Invalid email message', participationContent.form.validation.invalidEmail),
],
},
{
name: 'typeStep',
label: 'Step 1 · Submission type',
type: 'group',
fields: [
text('eyebrow', 'Eyebrow', participationContent.form.typeStep.eyebrow),
text('heading', 'Heading', participationContent.form.typeStep.heading),
text('selfTitle', 'Self-application title', participationContent.form.typeStep.selfTitle),
textarea('selfDesc', 'Self-application description', participationContent.form.typeStep.selfDesc),
text('nominationTitle', 'Nomination title', participationContent.form.typeStep.nominationTitle),
textarea('nominationDesc', 'Nomination description', participationContent.form.typeStep.nominationDesc),
],
},
{
name: 'selfSteps',
label: 'Self-application step labels',
type: 'array',
dbName: 'self_steps',
defaultValue: participationContent.form.selfSteps,
fields: [text('label', 'Label'), iconField('trophy')],
},
{
name: 'nominationSteps',
label: 'Nomination step labels',
type: 'array',
dbName: 'nom_steps',
defaultValue: participationContent.form.nominationSteps,
fields: [text('label', 'Label'), iconField('trophy')],
},
{
name: 'employeeOptions',
label: 'Employee options',
type: 'array',
dbName: 'emp_opts',
defaultValue: participationContent.form.employeeOptions,
fields: [text('value', 'Value'), text('label', 'Label')],
},
{
name: 'eligibility',
label: 'Self-application eligibility step',
type: 'group',
fields: [
text('allowedEmployeeValues', 'Allowed employee option values, comma-separated', participationContent.form.eligibility.allowedEmployeeValues),
text('requiredBayernValue', 'Required Bavaria answer value', participationContent.form.eligibility.requiredBayernValue),
text('eyebrow', 'Eyebrow', participationContent.form.eligibility.eyebrow),
text('heading', 'Heading', participationContent.form.eligibility.heading),
text('employeesLabel', 'Employees field label', participationContent.form.eligibility.employeesLabel),
text('bayernLabel', 'Bavaria field label', participationContent.form.eligibility.bayernLabel),
text('ownerLabel', 'Owner field label', participationContent.form.eligibility.ownerLabel),
text('ineligibleHeading', 'Ineligible heading', participationContent.form.eligibility.ineligibleHeading),
textarea('ineligibleBody', 'Ineligible body', participationContent.form.eligibility.ineligibleBody),
text('ineligibleContactPrefix', 'Ineligible contact prefix', participationContent.form.eligibility.ineligibleContactPrefix),
text('ineligibleContactEmail', 'Ineligible contact email', participationContent.form.eligibility.ineligibleContactEmail),
],
},
{
name: 'selfContact',
label: 'Self-application contact step',
type: 'group',
fields: Object.entries(participationContent.form.selfContact).map(([name, value]) => text(name, name, value)),
},
{
name: 'selfSummary',
label: 'Self-application summary step',
type: 'group',
fields: Object.entries(participationContent.form.selfSummary).map(([name, value]) => text(name, name, value)),
},
{
name: 'nominationCompany',
label: 'Nomination company step',
type: 'group',
fields: Object.entries(participationContent.form.nominationCompany).map(([name, value]) => text(name, name, value)),
},
{
name: 'nominationContact',
label: 'Nomination contact step',
type: 'group',
fields: Object.entries(participationContent.form.nominationContact).map(([name, value]) => text(name, name, value)),
},
{
name: 'nominationSummary',
label: 'Nomination summary step',
type: 'group',
fields: Object.entries(participationContent.form.nominationSummary).map(([name, value]) => text(name, name, value)),
},
{
name: 'success',
label: 'Success state copy',
type: 'group',
fields: Object.entries(participationContent.form.success).map(([name, value]) => text(name, name, value)),
},
],
},
],
},
]

View File

@@ -2,11 +2,20 @@ import type { Field } from 'payload'
import { pressIndexContent } from '@/spa/pressIndexContent'
const uploadField = (name: string, label: string, description?: string): Field => ({
const uploadField = (name: string, label: string, description?: string, imageOnly = false): Field => ({
name,
type: 'upload',
relationTo: 'media',
label,
...(imageOnly
? {
filterOptions: {
mimeType: {
contains: 'image',
},
},
}
: {}),
admin: description ? { description } : undefined,
})
@@ -29,32 +38,11 @@ const sectionAdmin = (description: string) => ({
initCollapsed: true,
})
const eventFields: Field[] = [
text('title', 'Title'),
text('date', 'Date'),
text('location', 'Location'),
text('cat', 'Category'),
text('status', 'Status'),
uploadField('image', 'Image'),
text('img', 'Image URL or path'),
textarea('desc', 'Description'),
text('slug', 'Route'),
]
const statusLabelFields: Field[] = [
text('value', 'Raw status value'),
text('label', 'Display label'),
]
const newsFields: Field[] = [
text('title', 'Title'),
textarea('excerpt', 'Excerpt'),
text('cat', 'Category'),
uploadField('image', 'Image'),
text('img', 'Image URL or path'),
text('slug', 'Route'),
]
export const pressIndexFields: Field[] = [
{
name: 'pressIndex',
@@ -62,7 +50,7 @@ export const pressIndexFields: Field[] = [
type: 'group',
admin: {
description:
'Edit the Presse listing page chrome. Event and news records are managed through Events and Presse collections; fallback cards remain editable here.',
'Edit the Presse listing page chrome. Event and news records are managed through Events and Presse collections.',
},
fields: [
{
@@ -82,12 +70,11 @@ export const pressIndexFields: Field[] = [
name: 'events',
label: '02 · Events section',
type: 'group',
admin: sectionAdmin('Section header, labels, and fallback event rows.'),
admin: sectionAdmin('Section header, labels, and missing-field presentation fallbacks.'),
fields: [
text('eyebrow', 'Eyebrow', pressIndexContent.events.eyebrow),
textarea('heading', 'Heading', pressIndexContent.events.heading),
textarea('description', 'Description', pressIndexContent.events.description),
text('detailLabel', 'Detail link label', pressIndexContent.events.detailLabel),
text('defaultStatusLabel', 'Missing status fallback label', pressIndexContent.events.defaultStatusLabel),
text('openStatusLabel', 'Open status label', pressIndexContent.events.openStatusLabel),
text('missingTitleLabel', 'Missing event title label', pressIndexContent.events.missingTitleLabel),
@@ -102,73 +89,41 @@ export const pressIndexFields: Field[] = [
defaultValue: pressIndexContent.events.statusLabels,
fields: statusLabelFields,
},
{
name: 'fallbackItems',
label: 'Fallback event rows',
type: 'array',
dbName: 'press_events_fb',
defaultValue: pressIndexContent.events.fallbackItems,
fields: eventFields,
},
],
},
{
name: 'news',
label: '03 · Newsroom section',
type: 'group',
admin: sectionAdmin('Section header, read-more label, and fallback news cards.'),
admin: sectionAdmin('Section header, read-more label, and missing-field presentation fallbacks.'),
fields: [
text('eyebrow', 'Eyebrow', pressIndexContent.news.eyebrow),
textarea('heading', 'Heading', pressIndexContent.news.heading),
textarea('description', 'Description', pressIndexContent.news.description),
text('readMoreLabel', 'Read more label', pressIndexContent.news.readMoreLabel),
text('missingTitleLabel', 'Missing news title label', pressIndexContent.news.missingTitleLabel),
text('missingCategoryLabel', 'Missing news category label', pressIndexContent.news.missingCategoryLabel),
uploadField('collectionFallbackImage', 'Missing news image fallback', `Current frontend image: /images/${pressIndexContent.news.collectionFallbackImageFilename}`),
{
name: 'fallbackItems',
label: 'Fallback news cards',
type: 'array',
dbName: 'press_news_fb',
defaultValue: pressIndexContent.news.fallbackItems,
fields: newsFields,
},
],
},
{
name: 'downloads',
label: '04 · Press material and contact',
type: 'group',
admin: sectionAdmin('Download button and press contact block.'),
admin: sectionAdmin('Press material introduction and contact block.'),
fields: [
text('eyebrow', 'Eyebrow', pressIndexContent.downloads.eyebrow),
textarea('heading', 'Heading', pressIndexContent.downloads.heading),
textarea('description', 'Description', pressIndexContent.downloads.description),
text('kitLabel', 'Kit label', pressIndexContent.downloads.kitLabel),
text('kitMeta', 'Kit metadata', pressIndexContent.downloads.kitMeta),
uploadField('kitFile', 'Press kit file', `Current frontend file: ${pressIndexContent.downloads.kitUrl}`),
text('kitUrl', 'Kit URL', pressIndexContent.downloads.kitUrl),
text('kitDownloadName', 'Kit download filename', pressIndexContent.downloads.kitDownloadName),
text('contactEyebrow', 'Contact eyebrow', pressIndexContent.downloads.contactEyebrow),
text('contactName', 'Contact name', pressIndexContent.downloads.contactName),
textarea('contactLines', 'Contact lines', pressIndexContent.downloads.contactLines),
],
},
{
name: 'accreditation',
label: '05 · Accreditation form copy',
type: 'group',
admin: sectionAdmin('Labels and success state for the press accreditation form.'),
fields: [
text('eyebrow', 'Eyebrow', pressIndexContent.accreditation.eyebrow),
textarea('heading', 'Heading', pressIndexContent.accreditation.heading),
textarea('description', 'Description', pressIndexContent.accreditation.description),
text('mediumLabel', 'Medium label', pressIndexContent.accreditation.mediumLabel),
text('nameLabel', 'Name label', pressIndexContent.accreditation.nameLabel),
text('emailLabel', 'Email label', pressIndexContent.accreditation.emailLabel),
text('submitLabel', 'Submit label', pressIndexContent.accreditation.submitLabel),
text('successHeading', 'Success heading', pressIndexContent.accreditation.successHeading),
textarea('successMessage', 'Success message', pressIndexContent.accreditation.successMessage),
uploadField(
'contactImage',
'Contact image',
'Optional image shown beside the press contact. Clear the selection to use the copy-only layout.',
true,
),
text('contactImageAlt', 'Contact image alt text', pressIndexContent.downloads.contactImageAlt),
],
},
],

View File

@@ -1,8 +1,14 @@
import type { CollectionConfig } from 'payload'
import type { CollectionConfig, NumberFieldSingleValidation } from 'payload'
import { authenticated } from '@/access/authenticated'
import { authenticatedOrPublished } from '@/access/authenticatedOrPublished'
import { populatePublishedAt } from '@/hooks/populatePublishedAt'
import { sponsorTierNumbers } from '@/spa/sponsorHierarchy'
export const validatePartnerTier: NumberFieldSingleValidation = (value) =>
typeof value === 'number' && sponsorTierNumbers.some((tier) => tier === value)
? true
: 'Tier must be one of 1, 2, or 3.'
export const Partners: CollectionConfig = {
slug: 'partners',
@@ -17,7 +23,15 @@ export const Partners: CollectionConfig = {
update: authenticated,
},
admin: {
defaultColumns: ['name', 'tier', 'sortOrder', 'active', 'showOnHomepage', 'updatedAt'],
defaultColumns: [
'name',
'tier',
'sortOrder',
'_status',
'active',
'showOnHomepage',
'updatedAt',
],
group: 'Inhalte',
useAsTitle: 'name',
},
@@ -40,14 +54,21 @@ export const Partners: CollectionConfig = {
name: 'tier',
type: 'number',
defaultValue: 3,
min: 1,
max: 3,
required: true,
validate: validatePartnerTier,
admin: {
description: 'Only tiers 1, 2, and 3 are supported.',
step: 1,
},
},
{
name: 'sortOrder',
type: 'number',
defaultValue: 100,
admin: {
description: 'Lower numbers appear earlier on partner surfaces.',
description: 'Lower numbers appear earlier within the selected tier on partner surfaces.',
},
},
{
@@ -68,7 +89,8 @@ export const Partners: CollectionConfig = {
relationTo: 'media',
required: true,
admin: {
description: 'Partner logo displayed on partner cards, the detail modal, and the homepage ticker.',
description:
'Partner logo displayed on partner cards, the detail modal, and the homepage ticker.',
},
},
{

View File

@@ -11,7 +11,7 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const path = doc.spaPath || `/posts/${doc.slug}`
const path = doc.spaPath || `/presse/blog/${doc.slug}`
payload.logger.info(`Revalidating post at path: ${path}`)
@@ -21,7 +21,7 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
// If the post was previously published, we need to revalidate the old path
if (previousDoc._status === 'published' && doc._status !== 'published') {
const oldPath = previousDoc.spaPath || `/posts/${previousDoc.slug}`
const oldPath = previousDoc.spaPath || `/presse/blog/${previousDoc.slug}`
payload.logger.info(`Revalidating old post at path: ${oldPath}`)
@@ -34,7 +34,7 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
export const revalidateDelete: CollectionAfterDeleteHook<Post> = ({ doc, req: { context } }) => {
if (!context.disableRevalidate) {
const path = doc?.spaPath || `/posts/${doc?.slug}`
const path = doc?.spaPath || `/presse/blog/${doc?.slug}`
revalidatePath(path)
revalidateTag('posts-sitemap', 'max')

View File

@@ -7,6 +7,11 @@ import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent'
import { generatePreviewPath } from '@/utilities/generatePreviewPath'
import { slugField } from 'payload'
import {
revalidatePreistraeger,
revalidatePreistraegerDelete,
} from './Preistraeger/hooks/revalidatePreistraeger'
import {
MetaDescriptionField,
MetaTitleField,
@@ -372,6 +377,8 @@ export const Preistraeger: CollectionConfig = {
slugField(),
],
hooks: {
afterChange: [revalidatePreistraeger],
afterDelete: [revalidatePreistraegerDelete],
beforeChange: [populatePublishedAt],
},
versions: {

View File

@@ -0,0 +1,46 @@
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath, revalidateTag } from 'next/cache'
import type { Preistraeger } from '../../../payload-types'
const preistraegerPath = (doc: Pick<Preistraeger, 'slug' | 'spaPath'>) =>
doc.spaPath || `/preistraeger/${doc.slug}`
export const revalidatePreistraeger: CollectionAfterChangeHook<Preistraeger> = ({
doc,
previousDoc,
req: { payload, context },
}) => {
if (!context.disableRevalidate) {
if (doc._status === 'published') {
const path = preistraegerPath(doc)
payload.logger.info(`Revalidating Preisträger at path: ${path}`)
revalidatePath(path)
revalidateTag('posts-sitemap', 'max')
}
if (previousDoc?._status === 'published' && doc._status !== 'published') {
const oldPath = preistraegerPath(previousDoc)
payload.logger.info(`Revalidating old Preisträger at path: ${oldPath}`)
revalidatePath(oldPath)
revalidateTag('posts-sitemap', 'max')
}
}
return doc
}
export const revalidatePreistraegerDelete: CollectionAfterDeleteHook<Preistraeger> = ({
doc,
req: { context },
}) => {
if (!context.disableRevalidate) {
revalidatePath(preistraegerPath(doc))
revalidateTag('posts-sitemap', 'max')
}
return doc
}

View File

@@ -0,0 +1,114 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
type MigrationDB = MigrateUpArgs['db']
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const fallbackTables = [
'press_events_fb',
'_press_events_fb_v',
'press_news_fb',
'_press_news_fb_v',
] as const
async function tableExists(db: MigrationDB, tableName: string) {
const rows = (await db.all(
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ name: string }>
return rows.length > 0
}
async function createIndex(db: MigrationDB, tableName: string, suffix: string, columnName: string) {
await db.run(
sql.raw(
`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_${suffix}_idx`)} ON ${ident(tableName)} (${ident(columnName)});`,
),
)
}
async function createLiveFallbackTable(
db: MigrationDB,
tableName: 'press_events_fb' | 'press_news_fb',
itemColumns: string,
) {
if (await tableExists(db, tableName)) return
await db.run(
sql.raw(`
CREATE TABLE ${ident(tableName)} (
${ident('_order')} integer NOT NULL,
${ident('_parent_id')} integer NOT NULL,
${ident('id')} text PRIMARY KEY NOT NULL,
${itemColumns},
FOREIGN KEY (${ident('image_id')}) REFERENCES ${ident('media')}(${ident('id')}) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident('pages')}(${ident('id')}) ON UPDATE no action ON DELETE cascade
);
`),
)
await createIndex(db, tableName, 'order', '_order')
await createIndex(db, tableName, 'parent_id', '_parent_id')
await createIndex(db, tableName, 'image', 'image_id')
}
async function createVersionFallbackTable(
db: MigrationDB,
tableName: '_press_events_fb_v' | '_press_news_fb_v',
itemColumns: string,
) {
if (await tableExists(db, tableName)) return
await db.run(
sql.raw(`
CREATE TABLE ${ident(tableName)} (
${ident('_order')} integer NOT NULL,
${ident('_parent_id')} integer NOT NULL,
${ident('id')} integer PRIMARY KEY NOT NULL,
${itemColumns},
${ident('_uuid')} text,
FOREIGN KEY (${ident('image_id')}) REFERENCES ${ident('media')}(${ident('id')}) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident('_pages_v')}(${ident('id')}) ON UPDATE no action ON DELETE cascade
);
`),
)
await createIndex(db, tableName, 'order', '_order')
await createIndex(db, tableName, 'parent_id', '_parent_id')
await createIndex(db, tableName, 'image', 'image_id')
}
const eventColumns = [
`${ident('title')} text`,
`${ident('date')} text`,
`${ident('location')} text`,
`${ident('cat')} text`,
`${ident('status')} text`,
`${ident('image_id')} integer`,
`${ident('img')} text`,
`${ident('desc')} text`,
`${ident('slug')} text`,
].join(',\n ')
const newsColumns = [
`${ident('title')} text`,
`${ident('excerpt')} text`,
`${ident('cat')} text`,
`${ident('image_id')} integer`,
`${ident('img')} text`,
`${ident('slug')} text`,
].join(',\n ')
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
for (const tableName of fallbackTables) {
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident(tableName)};`))
}
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await createLiveFallbackTable(db, 'press_events_fb', eventColumns)
await createVersionFallbackTable(db, '_press_events_fb_v', eventColumns)
await createLiveFallbackTable(db, 'press_news_fb', newsColumns)
await createVersionFallbackTable(db, '_press_news_fb_v', newsColumns)
}

View File

@@ -0,0 +1,397 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
import { homeContent } from '@/spa/homeContent'
type MigrationDB = MigrateUpArgs['db']
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const snake = (value: string) => value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
const obsoleteChildTables = [
'pages_home_application_benefits',
'_pages_v_version_home_application_benefits',
'home_self_steps',
'_home_self_steps_v',
'home_nom_steps',
'_home_nom_steps_v',
'home_emp_opts',
'_home_emp_opts_v',
'form_facts',
'_form_facts_v',
'self_steps',
'_self_steps_v',
'nom_steps',
'_nom_steps_v',
'emp_opts',
'_emp_opts_v',
'network_form_pkgs',
'_network_form_pkgs_v',
] as const
const livePrefixes = [
'home_application_',
'home_form_',
'participation_application_form_',
'participation_form_',
'about_highlights_',
'press_index_downloads_kit_',
'press_index_accreditation_',
] as const
const versionPrefixes = livePrefixes.map((prefix) => `version_${prefix}`)
const standaloneLiveColumns = [
'netzwerk_jury_note',
'netzwerk_partners_premium_label',
'netzwerk_partners_default_logo_color',
'netzwerk_sponsoring_footnote_prefix',
'netzwerk_sponsoring_footnote_label',
'netzwerk_sponsoring_footnote_url',
'netzwerk_sponsoring_form_eyebrow',
'netzwerk_sponsoring_form_step1_eyebrow',
'netzwerk_sponsoring_form_step1_heading',
'netzwerk_sponsoring_form_required_package_error',
'participation_eligibility_secondary_cta_label',
'participation_eligibility_secondary_cta_url',
'participation_eligibility_nudge_cta_label',
'participation_eligibility_nudge_cta_url',
'participation_application_ways_recommended_label',
'participation_application_ways_fallback_cta_label',
'participation_application_ways_fallback_cta_url',
'press_index_events_detail_label',
'press_index_news_read_more_label',
] as const
const obsoleteApplicationWayColumns = ['featured', 'cta_label', 'cta_url'] as const
const uploadIndexes = [
'pages_home_application_home_application_award_image_idx',
'_pages_v_version_home_application_version_home_applicati_idx',
'pages_participation_application_form_participation_appli_idx',
'_pages_v_version_participation_application_form_version__idx',
'pages_about_highlights_about_highlights_fallback_image_idx',
'_pages_v_version_about_highlights_version_about_highligh_idx',
'pages_press_index_downloads_press_index_downloads_kit_fi_idx',
'_pages_v_version_press_index_downloads_version_press_ind_idx',
] as const
function flattenScalars(value: Record<string, unknown>, prefix = ''): Array<[string, string]> {
return Object.entries(value).flatMap(([key, item]) => {
const path = prefix ? `${prefix}_${snake(key)}` : snake(key)
if (Array.isArray(item) || item === undefined || item === null) return []
if (typeof item === 'object') return flattenScalars(item as Record<string, unknown>, path)
return [[path, String(item)]]
})
}
const homeApplicationColumns = flattenScalars(homeContent.application as Record<string, unknown>)
.filter(([name]) => name !== 'award_image_filename')
.map(([name, value]) => [`home_application_${name}`, `text DEFAULT ${literal(value)}`] as const)
const sharedFormColumns = flattenScalars(homeContent.form as Record<string, unknown>)
const participationApplicationColumns = [
'image_alt',
'image_caption',
'eyebrow',
'heading',
'description',
'form_eyebrow',
'form_title',
'upload_cta_label',
'upload_cta_url',
'upload_prompt',
'upload_footer_cta_label',
'upload_footer_cta_url',
].map((suffix) => [`participation_application_form_${suffix}`, 'text'] as const)
const aboutHighlightColumns = [
'eyebrow',
'heading',
'cta_label',
'cta_url',
'hover_label',
'fallback_winner_name',
'fallback_winner_category',
'fallback_winner_award_type',
].map((suffix) => [`about_highlights_${suffix}`, 'text'] as const)
const pressColumns = [
'press_index_downloads_kit_label',
'press_index_downloads_kit_meta',
'press_index_downloads_kit_url',
'press_index_downloads_kit_download_name',
'press_index_accreditation_eyebrow',
'press_index_accreditation_heading',
'press_index_accreditation_description',
'press_index_accreditation_medium_label',
'press_index_accreditation_name_label',
'press_index_accreditation_email_label',
'press_index_accreditation_submit_label',
'press_index_accreditation_success_heading',
'press_index_accreditation_success_message',
].map((name) => [name, 'text'] as const)
const restoredLiveColumns = [
...homeApplicationColumns,
['home_application_award_caption', 'text'] as const,
['home_application_award_image_id', 'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null'] as const,
...sharedFormColumns.map(([name, value]) => [`home_form_${name}`, `text DEFAULT ${literal(value)}`] as const),
...participationApplicationColumns,
['participation_application_form_image_id', 'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null'] as const,
...sharedFormColumns.map(([name, value]) => [`participation_form_${name}`, `text DEFAULT ${literal(value)}`] as const),
...aboutHighlightColumns,
['about_highlights_fallback_image_id', 'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null'] as const,
['about_highlights_year', 'numeric'] as const,
...pressColumns,
['press_index_downloads_kit_file_id', 'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null'] as const,
...standaloneLiveColumns.map((name) => [name, 'text'] as const),
]
async function tableExists(db: MigrationDB, tableName: string) {
const rows = (await db.all(
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ name: string }>
return rows.length > 0
}
async function columnNames(db: MigrationDB, tableName: string) {
if (!(await tableExists(db, tableName))) return []
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }>
return columns.map((column) => column.name)
}
async function columnExists(db: MigrationDB, tableName: string, columnName: string) {
return (await columnNames(db, tableName)).includes(columnName)
}
async function addColumn(db: MigrationDB, tableName: string, columnName: string, definition: string) {
if (await columnExists(db, tableName, columnName)) return
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(columnName)} ${definition};`))
}
function splitDefinitions(value: string) {
const definitions: string[] = []
let current = ''
let depth = 0
let quote = ''
for (let index = 0; index < value.length; index += 1) {
const character = value[index]
const previous = value[index - 1]
if (quote) {
current += character
if (character === quote && previous !== '\\') quote = ''
continue
}
if (character === '`' || character === '"' || character === "'") {
quote = character
current += character
continue
}
if (character === '(') depth += 1
if (character === ')') depth -= 1
if (character === ',' && depth === 0) {
definitions.push(current.trim())
current = ''
continue
}
current += character
}
if (current.trim()) definitions.push(current.trim())
return definitions
}
function definitionColumnName(definition: string) {
const match = definition.trim().match(/^(?:`([^`]+)`|"([^"]+)"|\[([^\]]+)\]|([^\s]+))/)
return match?.[1] || match?.[2] || match?.[3] || match?.[4] || ''
}
function referencesColumn(definition: string, column: string) {
return [ident(column), `"${column}"`, `[${column}]`].some((value) => definition.includes(value))
}
async function rebuildTableWithoutColumns(
db: MigrationDB,
tableName: string,
removedColumns: readonly string[],
) {
if (removedColumns.length === 0) return
const schemaRows = (await db.all(
sql.raw(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ sql: string }>
const schema = schemaRows[0]?.sql
if (!schema) return
const open = schema.indexOf('(')
const close = schema.lastIndexOf(')')
if (open < 0 || close < open) throw new Error(`Could not parse schema for ${tableName}`)
const definitions = splitDefinitions(schema.slice(open + 1, close))
const keptDefinitions = definitions.filter((definition) => {
const columnName = definitionColumnName(definition)
const isConstraint = /^(CONSTRAINT|FOREIGN|PRIMARY|UNIQUE|CHECK)\b/i.test(columnName)
if (isConstraint) return !removedColumns.some((column) => referencesColumn(definition, column))
return !removedColumns.includes(columnName)
})
const keptColumns = (await columnNames(db, tableName)).filter((column) => !removedColumns.includes(column))
const indexRows = (await db.all(
sql.raw(`SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ${literal(tableName)} AND sql IS NOT NULL`),
)) as Array<{ sql: string }>
const keptIndexes = indexRows
.map((row) => row.sql)
.filter((indexSql) => !removedColumns.some((column) => referencesColumn(indexSql, column)))
const temporaryTable = `__task4_${tableName}`
const columnList = keptColumns.map(ident).join(', ')
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident(temporaryTable)};`))
await db.run(sql.raw(`CREATE TABLE ${ident(temporaryTable)} (${keptDefinitions.join(',\n')})${schema.slice(close + 1)};`))
await db.run(sql.raw(`INSERT INTO ${ident(temporaryTable)} (${columnList}) SELECT ${columnList} FROM ${ident(tableName)};`))
await db.run(sql.raw(`DROP TABLE ${ident(tableName)};`))
await db.run(sql.raw(`ALTER TABLE ${ident(temporaryTable)} RENAME TO ${ident(tableName)};`))
for (const indexSql of keptIndexes) await db.run(sql.raw(`${indexSql};`))
}
async function trimSponsoringSteps(db: MigrationDB, tableName: string) {
if (!(await tableExists(db, tableName))) return
await db.run(sql.raw(`
DELETE FROM ${ident(tableName)}
WHERE ${ident('id')} IN (
SELECT ${ident('id')}
FROM (
SELECT ${ident('id')}, ROW_NUMBER() OVER (
PARTITION BY ${ident('_parent_id')} ORDER BY ${ident('_order')} DESC
) AS row_number
FROM ${ident(tableName)}
)
WHERE row_number > 2
);
`))
await db.run(sql.raw(`
UPDATE ${ident(tableName)} AS current
SET ${ident('_order')} = (
SELECT COUNT(*)
FROM ${ident(tableName)} AS preceding
WHERE preceding.${ident('_parent_id')} = current.${ident('_parent_id')}
AND preceding.${ident('_order')} <= current.${ident('_order')}
);
`))
}
async function createArrayTable(
db: MigrationDB,
tableName: string,
parentTable: 'pages' | '_pages_v',
versioned: boolean,
fields: string,
) {
if (await tableExists(db, tableName)) return
await db.run(sql.raw(`
CREATE TABLE ${ident(tableName)} (
${ident('_order')} integer NOT NULL,
${ident('_parent_id')} integer NOT NULL,
${ident('id')} ${versioned ? 'integer' : 'text'} PRIMARY KEY NOT NULL,
${fields},
${versioned ? `${ident('_uuid')} text,` : ''}
FOREIGN KEY (${ident('_parent_id')}) REFERENCES ${ident(parentTable)}(${ident('id')}) ON UPDATE no action ON DELETE cascade
);
`))
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_order_idx`)} ON ${ident(tableName)} (${ident('_order')});`))
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_parent_id_idx`)} ON ${ident(tableName)} (${ident('_parent_id')});`))
}
async function restoreChildTables(db: MigrationDB) {
const textRows = `${ident('num')} text, ${ident('label')} text, ${ident('desc')} text`
const stepRows = `${ident('label')} text, ${ident('icon')} text DEFAULT 'trophy'`
const optionRows = `${ident('value')} text, ${ident('label')} text`
const packageRows = `${ident('value')} text, ${ident('title')} text, ${ident('desc')} text`
await createArrayTable(db, 'pages_home_application_benefits', 'pages', false, textRows)
await createArrayTable(db, '_pages_v_version_home_application_benefits', '_pages_v', true, textRows)
await createArrayTable(db, 'home_self_steps', 'pages', false, stepRows)
await createArrayTable(db, '_home_self_steps_v', '_pages_v', true, stepRows)
await createArrayTable(db, 'home_nom_steps', 'pages', false, stepRows)
await createArrayTable(db, '_home_nom_steps_v', '_pages_v', true, stepRows)
await createArrayTable(db, 'home_emp_opts', 'pages', false, optionRows)
await createArrayTable(db, '_home_emp_opts_v', '_pages_v', true, optionRows)
await createArrayTable(db, 'form_facts', 'pages', false, textRows)
await createArrayTable(db, '_form_facts_v', '_pages_v', true, textRows)
await createArrayTable(db, 'self_steps', 'pages', false, stepRows)
await createArrayTable(db, '_self_steps_v', '_pages_v', true, stepRows)
await createArrayTable(db, 'nom_steps', 'pages', false, stepRows)
await createArrayTable(db, '_nom_steps_v', '_pages_v', true, stepRows)
await createArrayTable(db, 'emp_opts', 'pages', false, optionRows)
await createArrayTable(db, '_emp_opts_v', '_pages_v', true, optionRows)
await createArrayTable(db, 'network_form_pkgs', 'pages', false, packageRows)
await createArrayTable(db, '_network_form_pkgs_v', '_pages_v', true, packageRows)
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await db.run(sql.raw('PRAGMA foreign_keys=OFF;'))
for (const indexName of uploadIndexes) {
await db.run(sql.raw(`DROP INDEX IF EXISTS ${ident(indexName)};`))
}
await db.run(sql.raw("DELETE FROM `pages_rels` WHERE `path` = 'about.highlights.selectedWinners';"))
await db.run(sql.raw("DELETE FROM `_pages_v_rels` WHERE `path` = 'version.about.highlights.selectedWinners';"))
for (const tableName of obsoleteChildTables) {
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident(tableName)};`))
}
const pageColumns = await columnNames(db, 'pages')
const removedPageColumns = pageColumns.filter(
(column) => standaloneLiveColumns.includes(column as (typeof standaloneLiveColumns)[number]) || livePrefixes.some((prefix) => column.startsWith(prefix)),
)
const versionColumns = await columnNames(db, '_pages_v')
const standaloneVersionColumns = standaloneLiveColumns.map((column) => `version_${column}`)
const removedVersionColumns = versionColumns.filter(
(column) => standaloneVersionColumns.includes(column) || versionPrefixes.some((prefix) => column.startsWith(prefix)),
)
await rebuildTableWithoutColumns(db, 'pages', removedPageColumns)
await rebuildTableWithoutColumns(db, '_pages_v', removedVersionColumns)
await rebuildTableWithoutColumns(db, 'app_ways', obsoleteApplicationWayColumns)
await rebuildTableWithoutColumns(db, '_app_ways_v', obsoleteApplicationWayColumns)
await trimSponsoringSteps(db, 'network_form_steps')
await trimSponsoringSteps(db, '_network_form_steps_v')
await db.run(sql.raw('PRAGMA foreign_keys=ON;'))
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
for (const [column, definition] of restoredLiveColumns) {
await addColumn(db, 'pages', column, definition)
await addColumn(db, '_pages_v', `version_${column}`, definition)
}
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `pages_home_application_home_application_award_image_idx` ON `pages` (`home_application_award_image_id`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `_pages_v_version_home_application_version_home_applicati_idx` ON `_pages_v` (`version_home_application_award_image_id`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `pages_participation_application_form_participation_appli_idx` ON `pages` (`participation_application_form_image_id`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `_pages_v_version_participation_application_form_version__idx` ON `_pages_v` (`version_participation_application_form_image_id`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `pages_about_highlights_about_highlights_fallback_image_idx` ON `pages` (`about_highlights_fallback_image_id`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `_pages_v_version_about_highlights_version_about_highligh_idx` ON `_pages_v` (`version_about_highlights_fallback_image_id`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `pages_press_index_downloads_press_index_downloads_kit_fi_idx` ON `pages` (`press_index_downloads_kit_file_id`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `_pages_v_version_press_index_downloads_version_press_ind_idx` ON `_pages_v` (`version_press_index_downloads_kit_file_id`);'))
await restoreChildTables(db)
await addColumn(db, 'app_ways', 'featured', 'integer DEFAULT false')
await addColumn(db, 'app_ways', 'cta_label', "text DEFAULT 'Zum Formular'")
await addColumn(db, 'app_ways', 'cta_url', "text DEFAULT '#bewerben'")
await addColumn(db, '_app_ways_v', 'featured', 'integer DEFAULT false')
await addColumn(db, '_app_ways_v', 'cta_label', "text DEFAULT 'Zum Formular'")
await addColumn(db, '_app_ways_v', 'cta_url', "text DEFAULT '#bewerben'")
}

View File

@@ -0,0 +1,169 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
import { netzwerkContent } from '@/spa/netzwerkContent'
type MigrationDB = MigrateUpArgs['db']
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const liveVideoColumn = 'netzwerk_patronage_greeting_video_id'
const versionVideoColumn = `version_${liveVideoColumn}`
const liveVideoIndex = 'pages_netzwerk_patronage_netzwerk_patronage_greeting_vid_idx'
const versionVideoIndex = '_pages_v_version_netzwerk_patronage_version_netzwerk_pat_idx'
const textColumns = [
['netzwerk_patronage_greeting_video_button_label', netzwerkContent.patronage.greetingVideoButtonLabel],
['netzwerk_patronage_greeting_video_title', netzwerkContent.patronage.greetingVideoTitle],
['netzwerk_patronage_greeting_video_description', netzwerkContent.patronage.greetingVideoDescription],
['netzwerk_patronage_greeting_video_close_label', netzwerkContent.patronage.greetingVideoCloseLabel],
] as const
async function tableExists(db: MigrationDB, tableName: string) {
const rows = (await db.all(
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ name: string }>
return rows.length > 0
}
async function columnExists(db: MigrationDB, tableName: string, columnName: string) {
if (!(await tableExists(db, tableName))) return false
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }>
return columns.some((column) => column.name === columnName)
}
async function columnNames(db: MigrationDB, tableName: string) {
if (!(await tableExists(db, tableName))) return []
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }>
return columns.map((column) => column.name)
}
async function addColumn(db: MigrationDB, tableName: string, columnName: string, definition: string) {
if (await columnExists(db, tableName, columnName)) return
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(columnName)} ${definition};`))
}
function splitDefinitions(value: string) {
const definitions: string[] = []
let current = ''
let depth = 0
let quote = ''
for (let index = 0; index < value.length; index += 1) {
const character = value[index]
const previous = value[index - 1]
if (quote) {
current += character
if (character === quote && previous !== '\\') quote = ''
continue
}
if (character === '`' || character === '"' || character === "'") {
quote = character
current += character
continue
}
if (character === '(') depth += 1
if (character === ')') depth -= 1
if (character === ',' && depth === 0) {
definitions.push(current.trim())
current = ''
continue
}
current += character
}
if (current.trim()) definitions.push(current.trim())
return definitions
}
function definitionColumnName(definition: string) {
const match = definition.trim().match(/^(?:`([^`]+)`|"([^"]+)"|\[([^\]]+)\]|([^\s]+))/)
return match?.[1] || match?.[2] || match?.[3] || match?.[4] || ''
}
function referencesColumn(definition: string, column: string) {
return [ident(column), `"${column}"`, `[${column}]`].some((value) => definition.includes(value))
}
async function rebuildTableWithoutColumns(db: MigrationDB, tableName: string, removedColumns: readonly string[]) {
const schemaRows = (await db.all(
sql.raw(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ sql: string }>
const schema = schemaRows[0]?.sql
if (!schema) return
const presentColumns = await columnNames(db, tableName)
const removed = removedColumns.filter((column) => presentColumns.includes(column))
if (!removed.length) return
const open = schema.indexOf('(')
const close = schema.lastIndexOf(')')
if (open < 0 || close < open) throw new Error(`Could not parse schema for ${tableName}`)
const keptDefinitions = splitDefinitions(schema.slice(open + 1, close)).filter((definition) => {
const columnName = definitionColumnName(definition)
const isConstraint = /^(CONSTRAINT|FOREIGN|PRIMARY|UNIQUE|CHECK)\b/i.test(columnName)
if (isConstraint) return !removed.some((column) => referencesColumn(definition, column))
return !removed.includes(columnName)
})
const keptColumns = presentColumns.filter((column) => !removed.includes(column))
const indexRows = (await db.all(
sql.raw(`SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ${literal(tableName)} AND sql IS NOT NULL`),
)) as Array<{ sql: string }>
const keptIndexes = indexRows
.map((row) => row.sql)
.filter((indexSql) => !removed.some((column) => referencesColumn(indexSql, column)))
const temporaryTable = `__task5_${tableName}`
const columnList = keptColumns.map(ident).join(', ')
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident(temporaryTable)};`))
await db.run(sql.raw(`CREATE TABLE ${ident(temporaryTable)} (${keptDefinitions.join(',\n')})${schema.slice(close + 1)};`))
await db.run(sql.raw(`INSERT INTO ${ident(temporaryTable)} (${columnList}) SELECT ${columnList} FROM ${ident(tableName)};`))
await db.run(sql.raw(`DROP TABLE ${ident(tableName)};`))
await db.run(sql.raw(`ALTER TABLE ${ident(temporaryTable)} RENAME TO ${ident(tableName)};`))
for (const indexSql of keptIndexes) await db.run(sql.raw(`${indexSql};`))
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await addColumn(
db,
'pages',
liveVideoColumn,
'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null',
)
await addColumn(
db,
'_pages_v',
versionVideoColumn,
'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null',
)
for (const [columnName, defaultValue] of textColumns) {
await addColumn(db, 'pages', columnName, `text DEFAULT ${literal(defaultValue)}`)
await addColumn(db, '_pages_v', `version_${columnName}`, `text DEFAULT ${literal(defaultValue)}`)
}
await db.run(
sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(liveVideoIndex)} ON ${ident('pages')} (${ident(liveVideoColumn)});`),
)
await db.run(
sql.raw(
`CREATE INDEX IF NOT EXISTS ${ident(versionVideoIndex)} ON ${ident('_pages_v')} (${ident(versionVideoColumn)});`,
),
)
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
const liveColumns = [liveVideoColumn, ...textColumns.map(([columnName]) => columnName)]
const versionColumns = [versionVideoColumn, ...textColumns.map(([columnName]) => `version_${columnName}`)]
await db.run(sql.raw('PRAGMA foreign_keys=OFF;'))
try {
await rebuildTableWithoutColumns(db, 'pages', liveColumns)
await rebuildTableWithoutColumns(db, '_pages_v', versionColumns)
} finally {
await db.run(sql.raw('PRAGMA foreign_keys=ON;'))
}
}

View File

@@ -0,0 +1,181 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
type MigrationDB = MigrateUpArgs['db']
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const liveImageColumn = 'press_index_downloads_contact_image_id'
const liveAltColumn = 'press_index_downloads_contact_image_alt'
const versionImageColumn = `version_${liveImageColumn}`
const versionAltColumn = `version_${liveAltColumn}`
const liveImageIndex = 'pages_press_index_downloads_press_index_downloads_contac_idx'
const versionImageIndex = '_pages_v_version_press_index_downloads_version_press_ind_idx'
async function tableExists(db: MigrationDB, tableName: string) {
const rows = (await db.all(
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ name: string }>
return rows.length > 0
}
async function columnExists(db: MigrationDB, tableName: string, columnName: string) {
if (!(await tableExists(db, tableName))) return false
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{
name: string
}>
return columns.some((column) => column.name === columnName)
}
async function columnNames(db: MigrationDB, tableName: string) {
if (!(await tableExists(db, tableName))) return []
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{
name: string
}>
return columns.map((column) => column.name)
}
async function addColumn(
db: MigrationDB,
tableName: string,
columnName: string,
definition: string,
) {
if (await columnExists(db, tableName, columnName)) return
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(columnName)} ${definition};`))
}
function splitDefinitions(value: string) {
const definitions: string[] = []
let current = ''
let depth = 0
let quote = ''
for (let index = 0; index < value.length; index += 1) {
const character = value[index]
const previous = value[index - 1]
if (quote) {
current += character
if (character === quote && previous !== '\\') quote = ''
continue
}
if (character === '`' || character === '"' || character === "'") {
quote = character
current += character
continue
}
if (character === '(') depth += 1
if (character === ')') depth -= 1
if (character === ',' && depth === 0) {
definitions.push(current.trim())
current = ''
continue
}
current += character
}
if (current.trim()) definitions.push(current.trim())
return definitions
}
function definitionColumnName(definition: string) {
const match = definition.trim().match(/^(?:`([^`]+)`|"([^"]+)"|\[([^\]]+)\]|([^\s]+))/)
return match?.[1] || match?.[2] || match?.[3] || match?.[4] || ''
}
function referencesColumn(definition: string, column: string) {
return [ident(column), `"${column}"`, `[${column}]`].some((value) => definition.includes(value))
}
async function rebuildTableWithoutColumns(
db: MigrationDB,
tableName: string,
removedColumns: readonly string[],
) {
const schemaRows = (await db.all(
sql.raw(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ sql: string }>
const schema = schemaRows[0]?.sql
if (!schema) return
const presentColumns = await columnNames(db, tableName)
const removed = removedColumns.filter((column) => presentColumns.includes(column))
if (!removed.length) return
const open = schema.indexOf('(')
const close = schema.lastIndexOf(')')
if (open < 0 || close < open) throw new Error(`Could not parse schema for ${tableName}`)
const keptDefinitions = splitDefinitions(schema.slice(open + 1, close)).filter((definition) => {
const columnName = definitionColumnName(definition)
const isConstraint = /^(CONSTRAINT|FOREIGN|PRIMARY|UNIQUE|CHECK)\b/i.test(columnName)
if (isConstraint) return !removed.some((column) => referencesColumn(definition, column))
return !removed.includes(columnName)
})
const keptColumns = presentColumns.filter((column) => !removed.includes(column))
const indexRows = (await db.all(
sql.raw(
`SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ${literal(tableName)} AND sql IS NOT NULL`,
),
)) as Array<{ sql: string }>
const keptIndexes = indexRows
.map((row) => row.sql)
.filter((indexSql) => !removed.some((column) => referencesColumn(indexSql, column)))
const temporaryTable = `__task6_${tableName}`
const columnList = keptColumns.map(ident).join(', ')
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident(temporaryTable)};`))
await db.run(
sql.raw(
`CREATE TABLE ${ident(temporaryTable)} (${keptDefinitions.join(',\n')})${schema.slice(close + 1)};`,
),
)
await db.run(
sql.raw(
`INSERT INTO ${ident(temporaryTable)} (${columnList}) SELECT ${columnList} FROM ${ident(tableName)};`,
),
)
await db.run(sql.raw(`DROP TABLE ${ident(tableName)};`))
await db.run(sql.raw(`ALTER TABLE ${ident(temporaryTable)} RENAME TO ${ident(tableName)};`))
for (const indexSql of keptIndexes) await db.run(sql.raw(`${indexSql};`))
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await addColumn(
db,
'pages',
liveImageColumn,
'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null',
)
await addColumn(db, 'pages', liveAltColumn, "text DEFAULT ''")
await addColumn(
db,
'_pages_v',
versionImageColumn,
'integer REFERENCES `media`(`id`) ON UPDATE no action ON DELETE set null',
)
await addColumn(db, '_pages_v', versionAltColumn, "text DEFAULT ''")
await db.run(
sql.raw(
`CREATE INDEX IF NOT EXISTS ${ident(liveImageIndex)} ON ${ident('pages')} (${ident(liveImageColumn)});`,
),
)
await db.run(
sql.raw(
`CREATE INDEX IF NOT EXISTS ${ident(versionImageIndex)} ON ${ident('_pages_v')} (${ident(versionImageColumn)});`,
),
)
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await db.run(sql.raw('PRAGMA foreign_keys=OFF;'))
try {
await rebuildTableWithoutColumns(db, 'pages', [liveImageColumn, liveAltColumn])
await rebuildTableWithoutColumns(db, '_pages_v', [versionImageColumn, versionAltColumn])
} finally {
await db.run(sql.raw('PRAGMA foreign_keys=ON;'))
}
}

View File

@@ -0,0 +1,66 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const previousDescription =
'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.'
const interimDescription =
'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Klicken für Details.'
const sponsorDescription =
'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Die gestaffelten Bänder machen die Gewichtung auf jeder Bildschirmgröße klar.'
const tierCopy = [
{ tier: 1, previousLabel: 'Hauptsponsoren', label: 'Große Darstellung' },
{ tier: 2, previousLabel: 'Medienpartner', label: 'Mittlere Darstellung' },
{ tier: 3, previousLabel: 'Weitere Sponsoren', label: 'Kompakte Darstellung' },
] as const
async function updateTierLabels(
db: MigrateUpArgs['db'],
tableName: 'network_tiers' | '_network_tiers_v',
direction: 'up' | 'down',
) {
for (const copy of tierCopy) {
const from = direction === 'up' ? copy.previousLabel : copy.label
const to = direction === 'up' ? copy.label : copy.previousLabel
await db.run(
sql.raw(
`UPDATE ${tableName} SET label = ${literal(to)} WHERE tier = ${copy.tier} AND label = ${literal(from)};`,
),
)
}
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await db.run(
sql.raw(
`UPDATE pages SET netzwerk_partners_description = ${literal(sponsorDescription)} WHERE netzwerk_partners_description IN (${literal(previousDescription)}, ${literal(interimDescription)});`,
),
)
await db.run(
sql.raw(
`UPDATE _pages_v SET version_netzwerk_partners_description = ${literal(sponsorDescription)} WHERE version_netzwerk_partners_description IN (${literal(previousDescription)}, ${literal(interimDescription)});`,
),
)
await updateTierLabels(db, 'network_tiers', 'up')
await updateTierLabels(db, '_network_tiers_v', 'up')
await db.run(sql.raw(`UPDATE network_tiers SET note = '' WHERE tier = 1 AND label = 'Große Darstellung' AND note = 'Premium';`))
await db.run(sql.raw(`UPDATE _network_tiers_v SET note = '' WHERE tier = 1 AND label = 'Große Darstellung' AND note = 'Premium';`))
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await db.run(
sql.raw(
`UPDATE pages SET netzwerk_partners_description = ${literal(previousDescription)} WHERE netzwerk_partners_description = ${literal(sponsorDescription)};`,
),
)
await db.run(
sql.raw(
`UPDATE _pages_v SET version_netzwerk_partners_description = ${literal(previousDescription)} WHERE version_netzwerk_partners_description = ${literal(sponsorDescription)};`,
),
)
await updateTierLabels(db, 'network_tiers', 'down')
await updateTierLabels(db, '_network_tiers_v', 'down')
await db.run(sql.raw(`UPDATE network_tiers SET note = 'Premium' WHERE tier = 1 AND label = 'Hauptsponsoren' AND note = '';`))
await db.run(sql.raw(`UPDATE _network_tiers_v SET note = 'Premium' WHERE tier = 1 AND label = 'Hauptsponsoren' AND note = '';`))
}

View File

@@ -0,0 +1,23 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const previousHeadline = 'DER BMP 2026 WAR EIN ERFOLG.'
const unbrokenHeadline = 'Ein weiteres erfolgreiches Jahr'
const splitHeadline = 'Ein weiteres\nerfolgreiches Jahr'
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await db.run(
sql.raw(
`UPDATE application_phase_phases SET headline = ${literal(splitHeadline)} WHERE num = '03' AND headline IN (${literal(previousHeadline)}, ${literal(unbrokenHeadline)});`,
),
)
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await db.run(
sql.raw(
`UPDATE application_phase_phases SET headline = ${literal(previousHeadline)} WHERE num = '03' AND headline = ${literal(splitHeadline)};`,
),
)
}

View File

@@ -15,6 +15,12 @@ import * as migration_20260713_163000_participation_supporting_awards_copy from
import * as migration_20260713_194500_contact_submissions from './20260713_194500_contact_submissions';
import * as migration_20260713_213000_netzwerk_jury_selection from './20260713_213000_netzwerk_jury_selection';
import * as migration_20260713_220000_preistraeger_detail_cleanup from './20260713_220000_preistraeger_detail_cleanup';
import * as migration_20260731_143000_remove_press_record_fallbacks from './20260731_143000_remove_press_record_fallbacks';
import * as migration_20260731_160000_focused_payload_cleanup from './20260731_160000_focused_payload_cleanup';
import * as migration_20260731_170000_netzwerk_greeting_video from './20260731_170000_netzwerk_greeting_video';
import * as migration_20260731_180000_press_contact_image from './20260731_180000_press_contact_image';
import * as migration_20260731_190000_responsive_sponsor_hierarchy from './20260731_190000_responsive_sponsor_hierarchy';
import * as migration_20260731_200000_home_phase_headline_line_break from './20260731_200000_home_phase_headline_line_break';
export const migrations = [
{
@@ -102,4 +108,34 @@ export const migrations = [
down: migration_20260713_220000_preistraeger_detail_cleanup.down,
name: '20260713_220000_preistraeger_detail_cleanup',
},
{
up: migration_20260731_143000_remove_press_record_fallbacks.up,
down: migration_20260731_143000_remove_press_record_fallbacks.down,
name: '20260731_143000_remove_press_record_fallbacks',
},
{
up: migration_20260731_160000_focused_payload_cleanup.up,
down: migration_20260731_160000_focused_payload_cleanup.down,
name: '20260731_160000_focused_payload_cleanup',
},
{
up: migration_20260731_170000_netzwerk_greeting_video.up,
down: migration_20260731_170000_netzwerk_greeting_video.down,
name: '20260731_170000_netzwerk_greeting_video',
},
{
up: migration_20260731_180000_press_contact_image.up,
down: migration_20260731_180000_press_contact_image.down,
name: '20260731_180000_press_contact_image',
},
{
up: migration_20260731_190000_responsive_sponsor_hierarchy.up,
down: migration_20260731_190000_responsive_sponsor_hierarchy.down,
name: '20260731_190000_responsive_sponsor_hierarchy',
},
{
up: migration_20260731_200000_home_phase_headline_line_break.up,
down: migration_20260731_200000_home_phase_headline_line_break.down,
name: '20260731_200000_home_phase_headline_line_break',
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -41,25 +41,15 @@ async function main() {
const page = pageResult.docs[0]
if (!page) throw new Error('About page not found. Expected spaPath=/der-bmp or slug=der-bmp/about.')
const [heroImage, prizeImage, mittelstandImage, fallbackWinnerImage, winnerResult] = await Promise.all([
const [heroImage, prizeImage, mittelstandImage] = await Promise.all([
mediaByFilename(payload, aboutContent.hero.backgroundImageFilename),
mediaByFilename(payload, aboutContent.prize.imageFilename),
mediaByFilename(payload, aboutContent.mittelstand.imageFilename),
mediaByFilename(payload, aboutContent.highlights.fallbackImageFilename),
payload.find({
collection: 'preistraeger',
limit: 8,
pagination: false,
draft: true,
overrideAccess: true,
where: { year: { equals: aboutContent.highlights.year } },
}),
])
const { backgroundImageFilename: _heroFilename, ...heroContent } = aboutContent.hero
const { imageFilename: _prizeFilename, ...prizeContent } = aboutContent.prize
const { imageFilename: _mittelstandFilename, ...mittelstandContent } = aboutContent.mittelstand
const { fallbackImageFilename: _highlightFallbackFilename, ...highlightsContent } = aboutContent.highlights
const testimonials = await testimonialsWithImages(payload)
await payload.update({
@@ -84,11 +74,6 @@ async function main() {
image: mittelstandImage,
body: aboutContent.mittelstand.body.map((text) => ({ text })),
},
highlights: {
...highlightsContent,
fallbackImage: fallbackWinnerImage,
selectedWinners: winnerResult.docs.map((winner) => winner.id),
},
testimonials,
},
} as never,

View File

@@ -41,12 +41,11 @@ async function main() {
const page = pageResult.docs[0]
if (!page) throw new Error('Home page not found. Expected spaPath=/ or slug=startseite/home.')
const [heroImage, quickImage, introImage, benefitsImage, awardImage] = await Promise.all([
const [heroImage, quickImage, introImage, benefitsImage] = await Promise.all([
mediaByFilename(payload, homeContent.hero.backgroundImageFilename),
mediaByFilename(payload, homeContent.quickCheck.imageFilename),
mediaByFilename(payload, homeContent.intro.imageFilename),
mediaByFilename(payload, homeContent.benefits.imageFilename),
mediaByFilename(payload, homeContent.application.awardImageFilename),
])
const fallbackWinnerImage = await mediaByFilename(payload, homeContent.winners.fallbackImageFilename)
const { backgroundImageFilename: _heroFilename, ...heroContent } = homeContent.hero
@@ -54,7 +53,6 @@ async function main() {
const { imageFilename: _introFilename, ...introContent } = homeContent.intro
const { fallbackImageFilename: _winnerFallbackFilename, ...winnersContent } = homeContent.winners
const { imageFilename: _benefitsFilename, ...benefitsContent } = homeContent.benefits
const { awardImageFilename: _awardFilename, ...applicationContent } = homeContent.application
const testimonials = await testimonialsWithImages(payload)
const winners = await payload.find({
@@ -94,11 +92,6 @@ async function main() {
...benefitsContent,
image: benefitsImage,
},
application: {
...applicationContent,
awardImage,
},
form: homeContent.form,
videoModal: homeContent.videoModal,
},
} as never,

View File

@@ -135,7 +135,8 @@ async function main() {
const sponsoringImage = await mediaByFilename(payload, netzwerkContent.sponsoring.imageFilename)
const { imageFilename: _heroFilename, ...heroContent } = netzwerkContent.hero
const { imageFilename: _sponsoringFilename, ...sponsoringContent } = netzwerkContent.sponsoring
const { items: _partnerItems, ...partnerSectionContent } = netzwerkContent.partners
const { items: _partnerItems, tiers: partnerTiers, ...partnerSectionContent } = netzwerkContent.partners
const existingGreetingVideo = page.netzwerk?.patronage?.greetingVideo || null
const [patronPeople, juryMembers] = await Promise.all([
itemsWithImageUploads(payload, netzwerkContent.patronage.people),
juryMembersForPage(payload),
@@ -155,6 +156,7 @@ async function main() {
},
patronage: {
...netzwerkContent.patronage,
greetingVideo: existingGreetingVideo,
people: patronPeople,
},
jury: {
@@ -167,6 +169,7 @@ async function main() {
},
partners: {
...partnerSectionContent,
tiers: partnerTiers.map(({ tier, label, note }) => ({ tier, label, note })),
},
},
} as never,

View File

@@ -23,13 +23,11 @@ async function main() {
const page = pageResult.docs[0]
if (!page) throw new Error('Participation page not found. Expected spaPath=/teilnahme or slug=teilnahme/participation.')
const [heroImage, formImage, ...awardCardImages] = await Promise.all([
const [heroImage, ...awardCardImages] = await Promise.all([
mediaByFilename(payload, participationContent.hero.backgroundImageFilename),
mediaByFilename(payload, participationContent.applicationForm.imageFilename),
...participationContent.awardsGrid.cards.map((card) => mediaByFilename(payload, card.imageFilename)),
])
const { backgroundImageFilename: _heroFilename, ...heroContent } = participationContent.hero
const { imageFilename: _formFilename, ...applicationFormContent } = participationContent.applicationForm
const { cards: awardCards, ...awardsGridContent } = participationContent.awardsGrid
await payload.update({
@@ -54,11 +52,6 @@ async function main() {
image: awardCardImages[index],
})),
},
applicationForm: {
...applicationFormContent,
image: formImage,
},
form: participationContent.form,
},
} as never,
})

View File

@@ -1,27 +1,11 @@
import 'dotenv/config'
import config from '@payload-config'
import { getPayload, type Payload } from 'payload'
import { getPayload } from 'payload'
import { mediaByFilename } from './mediaByFilename'
import { pressIndexContent } from '@/spa/pressIndexContent'
const filenameFromPath = (source: string) => source.split('?')[0]?.split('/').filter(Boolean).pop()
const fallbackItemsWithImages = async <T extends { img?: string }>(
payload: Payload,
items: T[],
) =>
Promise.all(
items.map(async (item) => {
const filename = item.img ? filenameFromPath(item.img) : undefined
return {
...item,
image: filename ? await mediaByFilename(payload, filename) : undefined,
}
}),
)
async function main() {
const payload = await getPayload({ config })
@@ -39,20 +23,19 @@ async function main() {
const page = pageResult.docs[0]
if (!page) throw new Error('Press index page not found. Expected spaPath=/presse or slug=presse/press.')
const [heroImage, eventFallbackImage, newsFallbackImage, kitFile] = await Promise.all([
const [heroImage, eventFallbackImage, newsFallbackImage] = await Promise.all([
mediaByFilename(payload, pressIndexContent.hero.imageFilename),
mediaByFilename(payload, pressIndexContent.events.collectionFallbackImageFilename),
mediaByFilename(payload, pressIndexContent.news.collectionFallbackImageFilename),
mediaByFilename(payload, pressIndexContent.downloads.kitDownloadName),
])
const { imageFilename: _heroFilename, ...heroContent } = pressIndexContent.hero
const { collectionFallbackImageFilename: _eventFallbackFilename, ...eventsContent } = pressIndexContent.events
const { collectionFallbackImageFilename: _newsFallbackFilename, ...newsContent } = pressIndexContent.news
const [eventFallbackItems, newsFallbackItems] = await Promise.all([
fallbackItemsWithImages(payload, pressIndexContent.events.fallbackItems),
fallbackItemsWithImages(payload, pressIndexContent.news.fallbackItems),
])
const currentContactImage = page.pressIndex?.downloads?.contactImage
const currentContactImageID =
currentContactImage && typeof currentContactImage === 'object'
? currentContactImage.id
: currentContactImage
await payload.update({
collection: 'pages',
id: page.id,
@@ -68,16 +51,16 @@ async function main() {
events: {
...eventsContent,
collectionFallbackImage: eventFallbackImage,
fallbackItems: eventFallbackItems,
},
news: {
...newsContent,
collectionFallbackImage: newsFallbackImage,
fallbackItems: newsFallbackItems,
},
downloads: {
...pressIndexContent.downloads,
kitFile,
contactImage: currentContactImageID ?? null,
contactImageAlt:
page.pressIndex?.downloads?.contactImageAlt ?? pressIndexContent.downloads.contactImageAlt,
},
},
} as never,

View File

@@ -43,20 +43,6 @@ export const aboutContent = {
'In einer globalisierten Welt sind es diese Unternehmen, die für Stabilität, Innovation und soziale Verantwortung stehen. Der BMP macht sie sichtbar.',
],
},
highlights: {
fallbackImageFilename: 'gala-saal-overview.jpg',
eyebrow: 'Preisträger-Highlights',
heading: 'AUSGEZEICHNETE 2025',
cta: { label: 'Alle Preisträger', url: '/preistraeger' },
hoverLabel: 'Detail →',
year: 2025,
fallbackWinner: {
name: 'Preisträger',
category: 'Preisträger',
awardType: 'Auszeichnung',
},
selectedWinners: [],
},
testimonials: testimonialsContent,
history: {
eyebrow: 'Geschichte & Meilensteine',

View File

@@ -70,7 +70,7 @@ export const defaultApplicationPhaseData: SpaApplicationPhaseData = {
tag: 'ABGESCHLOSSEN',
pulse: false,
phase: 'Preisverleihung · Oktober 2026',
headline: 'DER BMP 2026 WAR EIN ERFOLG.',
headline: 'Ein weiteres\nerfolgreiches Jahr',
body:
'Wir danken allen 247 Bewerberinnen und Bewerbern für Mut und Exzellenz. Drei außergewöhnliche Unternehmen wurden in München ausgezeichnet – ein unvergesslicher Abend.',
cta: { label: 'Alle Preisträger ansehen', url: '/preistraeger' },

View File

@@ -4,6 +4,8 @@ type MediaLike = {
url?: string | null
filename?: string | null
alt?: string | null
focalX?: number | null
focalY?: number | null
}
export function normalizeMediaUrl(url: string | null | undefined) {
@@ -22,6 +24,19 @@ export function mediaAlt(media: unknown, fallback: string) {
return doc.alt || fallback
}
const focalCoordinate = (value: unknown) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return 50
return Math.min(100, Math.max(0, value))
}
export function mediaObjectPosition(media: unknown, fallback = 'center') {
if (!media || typeof media !== 'object') return fallback
const doc = media as MediaLike
if (typeof doc.focalX !== 'number' && typeof doc.focalY !== 'number') return fallback
return `${focalCoordinate(doc.focalX)}% ${focalCoordinate(doc.focalY)}%`
}
export function docImageUrl(doc: CmsRouteDoc | undefined, fallback: string, field = 'image') {
return mediaUrl(doc?.[field], fallback)
}

View File

@@ -7,7 +7,8 @@ import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import type { SpaNewsletterFormData } from '@/spa/newsletterForm'
import type { SpaSiteSettingsData } from '@/spa/siteSettings'
export type CmsRouteCollection = 'pages' | 'posts' | 'events' | 'preistraeger' | 'partners' | 'jury-mitglieder'
export type CmsRouteCollection =
'pages' | 'posts' | 'events' | 'preistraeger' | 'partners' | 'jury-mitglieder'
export type CmsRouteDoc = {
id: number | string
@@ -34,7 +35,13 @@ export type CmsRouteData = {
const CmsRouteContext = createContext<CmsRouteData | undefined>(undefined)
export function CmsRouteProvider({ children, value }: { children: React.ReactNode; value?: CmsRouteData }) {
export function CmsRouteProvider({
children,
value,
}: {
children: React.ReactNode
value?: CmsRouteData
}) {
return <CmsRouteContext.Provider value={value}>{children}</CmsRouteContext.Provider>
}
@@ -48,10 +55,17 @@ export function useCmsCollection(collection: CmsRouteCollection) {
export function resolveCmsRelationship(entry: unknown, docs: CmsRouteDoc[]) {
const entryID = entry && typeof entry === 'object' ? (entry as { id?: unknown }).id : entry
const populatedDoc = docs.find((doc) => String(doc.id) === String(entryID))
if (entryID === undefined || entryID === null) return undefined
if (populatedDoc) return populatedDoc
return entry && typeof entry === 'object' ? (entry as CmsRouteDoc) : undefined
return docs.find((doc) => String(doc.id) === String(entryID))
}
export function resolveCmsRelationships(entries: unknown, docs: CmsRouteDoc[]) {
if (!Array.isArray(entries)) return []
return entries
.map((entry) => resolveCmsRelationship(entry, docs))
.filter((doc): doc is CmsRouteDoc => Boolean(doc))
}
export function useApplicationPhase() {

View File

@@ -102,7 +102,7 @@ export default function HomeWinnersSection({ content }: { content?: HomeWinnersC
const placeholderImage = usePreistraegerPlaceholderImage();
const cmsPreistraeger = useCmsCollection('preistraeger');
const winnersSection = content || {};
const homeWinners = resolveHomeWinners(winnersSection.featured, cmsPreistraeger, WINNERS);
const homeWinners = resolveHomeWinners(winnersSection.featured, cmsPreistraeger);
const scrollDesktopWinners = !isMobile && homeWinners.length > 8;
const scrollWinnerGrid = isMobile || scrollDesktopWinners;

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { FileText, Search, UserPlus, Trophy } from 'lucide-react';
import { ChevronLeft, ChevronRight, FileText, Search, UserPlus, Trophy } from 'lucide-react';
import {
ContainerScroll,
ContainerSticky,
getActiveTimelineStep,
getTimelineScrollBehavior,
getTimelineStepScrollLeft,
ProcessCard,
ProcessCardBody,
ProcessCardTitle,
@@ -34,12 +35,69 @@ function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
}
function usePrefersReducedMotion() {
const query = '(prefers-reduced-motion: reduce)';
return React.useSyncExternalStore(
(onStoreChange) => {
const mediaQuery = window.matchMedia(query);
mediaQuery.addEventListener('change', onStoreChange);
return () => mediaQuery.removeEventListener('change', onStoreChange);
},
() => window.matchMedia(query).matches,
() => false,
);
}
export default function WegZurAuszeichnungSection({ content }: { content?: ProcessContent }) {
const isMobile = useIsMobile();
const prefersReducedMotion = usePrefersReducedMotion();
const trackRef = React.useRef<HTMLOListElement>(null);
const [activeStep, setActiveStep] = React.useState(0);
const section = { ...participationContent.process, ...(content || {}) };
const steps = fallbackArray(section.steps, participationContent.process.steps);
const stepPrefix = fallbackText(section.stepPrefix, participationContent.process.stepPrefix);
const scrollToStep = React.useCallback((index: number) => {
const track = trackRef.current;
if (!track) return;
const nextStep = Math.min(steps.length - 1, Math.max(0, index));
const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth);
track.scrollTo({
behavior: getTimelineScrollBehavior(prefersReducedMotion),
left: getTimelineStepScrollLeft(nextStep, maxScroll, steps.length),
});
setActiveStep(nextStep);
}, [prefersReducedMotion, steps.length]);
const handleTrackScroll = React.useCallback(() => {
const track = trackRef.current;
if (!track) return;
setActiveStep(getActiveTimelineStep(
track.scrollLeft,
Math.max(0, track.scrollWidth - track.clientWidth),
steps.length,
));
}, [steps.length]);
const handleTrackKeyDown = (event: React.KeyboardEvent<HTMLOListElement>) => {
if (event.key === 'ArrowRight') {
event.preventDefault();
scrollToStep(activeStep + 1);
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollToStep(activeStep - 1);
} else if (event.key === 'Home') {
event.preventDefault();
scrollToStep(0);
} else if (event.key === 'End') {
event.preventDefault();
scrollToStep(steps.length - 1);
}
};
// ── MOBILE: stacked vertical list (no scroll-jack, no horizontal overflow) ──
if (isMobile) {
return (
@@ -94,31 +152,29 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
}
return (
<ContainerScroll id="schritte" className="h-[300vh]" style={{ background: NAVY }}>
<ContainerSticky className="top-0 h-screen flex flex-col">
<section id="schritte" aria-labelledby="process-heading" style={{ background: NAVY }}>
{/* Section header */}
<div style={{ padding: '56px 80px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, alignItems: 'flex-end', borderBottom: '1px solid rgba(255,255,255,0.07)', flexShrink: 0 }}>
{/* Section header */}
<div className="process-timeline-header" style={{ padding: '56px 80px 40px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 40, alignItems: 'flex-end', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
<div>
<span style={{ fontFamily: FF, fontSize: 10, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 14 }}>{fallbackText(section.eyebrow, participationContent.process.eyebrow)}</span>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0 }}>
<h2 id="process-heading" className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0 }}>
<Lines text={fallbackText(section.heading, participationContent.process.heading)} />
</h2>
</div>
<p className="type-small text-role-inverse-secondary" style={{ margin: 0 }}>
{fallbackText(section.description, participationContent.process.description)}
</p>
</div>
</div>
{/* Scroll hint – prominent */}
<div style={{ padding: '20px 80px', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
{/* Animated chevron stack */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{[0, 1, 2].map(i => (
{/* Controls and real progress state */}
<div className="process-timeline-controls" style={{ padding: '20px 80px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div aria-hidden="true" style={{ display: 'flex', gap: 2 }}>
{[0, 1, 2].map((i) => (
<svg
key={i}
width="12" height="7" viewBox="0 0 12 7" fill="none"
style={{ opacity: 1 - i * 0.3, animation: `scrollBounce 1.6s ease-in-out ${i * 0.18}s infinite` }}
style={{ opacity: 1 - i * 0.3, transform: 'rotate(-90deg)' }}
>
<polyline points="1,1 6,6 11,1" stroke={GOLD} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
@@ -127,24 +183,57 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.55)' }}>
{fallbackText(section.scrollHint, participationContent.process.scrollHint)}
</span>
{/* Progress bar */}
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.07)', position: 'relative', overflow: 'hidden', maxWidth: 200 }}>
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: '25%', background: GOLD, opacity: 0.6 }} />
<div data-testid="timeline-progress" style={{ position: 'absolute', left: 0, top: 0, height: '100%', width: `${((activeStep + 1) / steps.length) * 100}%`, background: GOLD, opacity: 0.6, transition: prefersReducedMotion ? 'none' : 'width 0.2s ease' }} />
</div>
<span style={{ fontFamily: FF, fontSize: 9, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.1em' }}>{fallbackText(section.progressLabel, participationContent.process.progressLabel)}</span>
</div>
<span aria-live="polite" data-testid="timeline-progress-label" style={{ fontFamily: FF, fontSize: 9, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.1em', minWidth: 44 }}>
{String(activeStep + 1).padStart(2, '0')} / {String(steps.length).padStart(2, '0')}
</span>
<div style={{ display: 'flex', gap: 8, marginLeft: 'auto' }}>
<button
type="button"
aria-label="Vorheriger Schritt"
aria-controls="participation-timeline-track"
disabled={activeStep === 0}
onClick={() => scrollToStep(activeStep - 1)}
className="process-timeline-button"
>
<ChevronLeft size={18} aria-hidden="true" />
</button>
<button
type="button"
aria-label="Nächster Schritt"
aria-controls="participation-timeline-track"
disabled={activeStep === steps.length - 1}
onClick={() => scrollToStep(activeStep + 1)}
className="process-timeline-button"
>
<ChevronRight size={18} aria-hidden="true" />
</button>
</div>
</div>
{/* Cards strip */}
<div className="flex flex-nowrap flex-1 items-stretch px-20 pb-16 gap-0">
{/* Native horizontal carousel: wheel/trackpad, touch, controls, and keyboard all work. */}
<div className="process-timeline-card-shell">
<ol
ref={trackRef}
id="participation-timeline-track"
data-testid="timeline-track"
aria-label="Schritte zum Preis"
tabIndex={0}
onKeyDown={handleTrackKeyDown}
onScroll={handleTrackScroll}
className="process-timeline-track"
>
{steps.map((item, index) => {
const Icon = ICONS[item.icon as keyof typeof ICONS] || FileText;
return (
<ProcessCard
key={`${item.step}-${index}`}
itemsLength={steps.length}
index={index}
variant="bmp"
className="h-[300px] self-start min-w-[70%] max-w-[70%] flex-shrink-0"
data-testid={`timeline-step-${index + 1}`}
aria-label={`${stepPrefix} ${fallbackText(item.step, String(index + 1))}: ${fallbackText(item.title, '')}`}
className="process-timeline-card"
>
{/* Left accent strip with step number */}
<ProcessCardTitle className="border-r border-[rgba(239,191,4,0.2)] flex flex-col items-center justify-between py-6 px-4 min-w-[64px]">
@@ -175,24 +264,88 @@ export default function WegZurAuszeichnungSection({ content }: { content?: Proce
{fallbackText(item.desc, '')}
</p>
{/* Step progress indicator */}
<div style={{ display: 'flex', gap: 4 }}>
<div aria-hidden="true" style={{ display: 'flex', gap: 4 }}>
{steps.map((_, i) => (
<div key={i} style={{ height: 2, flex: i === index ? 2 : 1, background: i === index ? GOLD : 'rgba(255,255,255,0.1)', transition: 'flex 0.3s' }} />
<div
key={i}
style={{ height: 2, flex: i === activeStep ? 2 : 1, background: i === activeStep ? GOLD : 'rgba(255,255,255,0.1)', transition: prefersReducedMotion ? 'none' : 'flex 0.2s ease' }}
/>
))}
</div>
</ProcessCardBody>
</ProcessCard>
);
})}
</div>
</ContainerSticky>
</ol>
</div>
<style>{`
@keyframes scrollBounce {
0%, 100% { transform: translateY(0); opacity: inherit; }
50% { transform: translateY(3px); }
.process-timeline-card-shell {
padding: 0 80px 52px;
}
.process-timeline-track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: min(75%, 980px);
gap: 16px;
margin: 0;
padding: 0 0 12px;
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-snap-type: inline mandatory;
scrollbar-color: rgba(239,191,4,0.55) rgba(255,255,255,0.08);
scrollbar-width: thin;
}
.process-timeline-card {
min-height: 320px;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.process-timeline-track:focus-visible,
.process-timeline-button:focus-visible {
outline: 2px solid ${GOLD};
outline-offset: 3px;
}
.process-timeline-button {
width: 38px;
height: 38px;
border: 1px solid rgba(239,191,4,0.45);
background: transparent;
color: ${GOLD};
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.process-timeline-button:disabled {
opacity: 0.3;
cursor: default;
}
@media (max-width: 1024px) {
.process-timeline-header {
padding: 48px 48px 36px !important;
}
.process-timeline-controls {
padding: 18px 48px !important;
}
.process-timeline-card-shell {
padding: 0 48px 44px;
}
.process-timeline-track {
grid-auto-columns: 82%;
}
}
@media (prefers-reduced-motion: reduce) {
.process-timeline-track {
scroll-behavior: auto;
}
.process-timeline-card,
.process-timeline-card * {
transition: none !important;
animation: none !important;
}
}
`}</style>
</ContainerScroll>
</section>
);
}

View File

@@ -42,12 +42,17 @@ export function ApplicationFormSection({ source }: { source: NewsletterSource })
const newsletterPhase = newsletterPhaseForApplicationPhase(activeApplicationPhase)
const benefits = fallbackArray<NewsletterBenefit>(section.benefits, homeContent.application.benefits)
const finalBenefits = showApplicationForm ? benefits : newsletterCopy.benefits
const desktopGridTemplateColumns = source === 'homepage' ? '5fr 1px 7fr' : '4fr 1px 8fr'
return (
<section style={{ background: '#24366A', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : showApplicationForm ? 'calc(100vh - 60px)' : 'auto', minHeight: isMobile ? undefined : showApplicationForm ? undefined : 560, display: 'flex', flexDirection: 'column' }} id="bewerben">
<div style={{ position: 'absolute', left: -120, top: '50%', transform: 'translateY(-50%)', width: 600, height: 600, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, transparent 70%)', pointerEvents: 'none' }} />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', position: 'relative', flex: isMobile ? 'none' : 1, minHeight: 0, overflow: 'hidden' }}>
<div
data-testid="application-form-layout"
data-source={source}
style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : desktopGridTemplateColumns, position: 'relative', flex: isMobile ? 'none' : 1, minHeight: 0, overflow: 'hidden' }}
>
<div style={{ padding: isMobile ? '32px 24px 24px' : '36px 36px 32px 52px', display: 'flex', flexDirection: 'column', justifyContent: 'center', overflow: 'hidden' }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.36em', fontWeight: 700, display: 'block', marginBottom: 16, opacity: 0.8 }}>{showApplicationForm ? fallbackText(section.eyebrow, homeContent.application.eyebrow) : newsletterCopy.eyebrow}</span>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', marginBottom: 16 }}>

View File

@@ -147,8 +147,7 @@ export default function SponsoringForm({ theme = 'dark', content }: { theme?: 'd
const isMobile = useIsMobile();
const applicationPhase = useApplicationPhase();
const copy = { ...netzwerkContent.sponsoringForm, ...(content || {}) };
const configuredSteps = fallbackArray<StepOption>(copy.steps, netzwerkContent.sponsoringForm.steps);
const steps = configuredSteps.slice(-2);
const steps = fallbackArray<StepOption>(copy.steps, netzwerkContent.sponsoringForm.steps);
const [step, setStep] = useState(0);
const [dir, setDir] = useState(1);
const [submitted, setSubmitted] = useState(false);

View File

@@ -1,52 +1,151 @@
import React from "react";
import React, { useState } from 'react'
interface PartnerLogo {
id: string;
label: React.ReactNode;
id: string
label: React.ReactNode
}
interface PartnerTickerProps {
logos: PartnerLogo[];
ariaLabel: string
logos: PartnerLogo[]
}
export function PartnerTicker({ logos }: PartnerTickerProps) {
// Duplicate logos for seamless infinite loop
const track = [...logos, ...logos];
export const PARTNER_TICKER_ITEM_WIDTH_PX = 260
export const PARTNER_TICKER_SPEED_PX_PER_SECOND = 48
export const PARTNER_TICKER_MIN_DURATION_SECONDS = 24
export function partnerTickerDurationSeconds(logoCount: number) {
if (logoCount <= 0) return 0
const loopDistance = logoCount * PARTNER_TICKER_ITEM_WIDTH_PX
return Math.max(
PARTNER_TICKER_MIN_DURATION_SECONDS,
loopDistance / PARTNER_TICKER_SPEED_PX_PER_SECOND,
)
}
export function PartnerTicker({ ariaLabel, logos }: PartnerTickerProps) {
const [hasFocusWithin, setHasFocusWithin] = useState(false)
const [isHovered, setIsHovered] = useState(false)
if (logos.length === 0) return null
const durationSeconds = partnerTickerDurationSeconds(logos.length)
const isPaused = hasFocusWithin || isHovered
const renderLogoCopy = (copy: number) => (
<div
aria-hidden={copy === 1 ? 'true' : undefined}
className="partner-ticker-copy"
data-testid="partner-ticker-copy"
key={copy}
style={{ display: 'flex' }}
>
{logos.map((logo) => (
<div
key={`${copy}-${logo.id}`}
style={{
alignItems: 'center',
color: '#fff',
display: 'flex',
flexShrink: 0,
fontFamily: '"IBM Plex Sans", sans-serif',
justifyContent: 'center',
padding: '0 24px',
textAlign: 'center',
whiteSpace: 'nowrap',
width: PARTNER_TICKER_ITEM_WIDTH_PX,
}}
>
{logo.label}
</div>
))}
</div>
)
return (
<div style={{ overflow: 'hidden', flex: 1, minWidth: 0, position: 'relative' }}>
{/* fade edges */}
<div style={{ position: 'absolute', inset: 0, left: 0, width: 32, background: 'linear-gradient(to right, rgba(5,26,135,0.6), transparent)', zIndex: 1, pointerEvents: 'none' }} />
<div style={{ position: 'absolute', inset: 0, right: 0, left: 'auto', width: 32, background: 'linear-gradient(to left, rgba(5,26,135,0.6), transparent)', zIndex: 1, pointerEvents: 'none' }} />
<div
aria-label={ariaLabel}
className="partner-ticker-viewport"
data-duration-seconds={durationSeconds}
data-testid="partner-ticker"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setHasFocusWithin(false)
}
}}
onFocus={() => setHasFocusWithin(true)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
role="region"
style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative' }}
tabIndex={0}
>
<div
style={{
background: 'linear-gradient(to right, rgba(5,26,135,0.6), transparent)',
inset: 0,
left: 0,
pointerEvents: 'none',
position: 'absolute',
width: 32,
zIndex: 1,
}}
/>
<div
style={{
background: 'linear-gradient(to left, rgba(5,26,135,0.6), transparent)',
inset: 0,
left: 'auto',
pointerEvents: 'none',
position: 'absolute',
right: 0,
width: 32,
zIndex: 1,
}}
/>
<div style={{ display: 'flex', animation: 'marquee 22s linear infinite', width: 'max-content' }}>
{track.map((logo, i) => (
<div
key={`${logo.id}-${i}`}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 24px',
color: '#fff',
fontFamily: '"IBM Plex Sans", sans-serif',
whiteSpace: 'nowrap',
width: 260,
flexShrink: 0,
textAlign: 'center',
}}
>
{logo.label}
</div>
))}
<div
className="partner-ticker-track"
data-testid="partner-ticker-track"
style={{
animation: `partner-marquee ${durationSeconds}s linear infinite`,
animationPlayState: isPaused ? 'paused' : 'running',
display: 'flex',
width: 'max-content',
}}
>
{renderLogoCopy(0)}
{renderLogoCopy(1)}
</div>
<style>{`
@keyframes marquee {
@keyframes partner-marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
to { transform: translateX(-50%); }
}
.partner-ticker-viewport:hover .partner-ticker-track,
.partner-ticker-viewport:focus-within .partner-ticker-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.partner-ticker-viewport {
overflow-x: auto !important;
overscroll-behavior-x: contain;
}
.partner-ticker-track {
animation: none !important;
transform: none !important;
}
.partner-ticker-copy[aria-hidden="true"] {
display: none !important;
}
}
`}</style>
</div>
);
)
}

View File

@@ -2,117 +2,64 @@
import * as React from "react"
import { useMeasure } from "@uidotdev/usehooks"
import { VariantProps, cva } from "class-variance-authority"
import {
HTMLMotionProps,
MotionValue,
motion,
useScroll,
useTransform,
} from "motion/react"
import { cn } from "@/spa/lib/utils"
const processCardVariants = cva("flex border backdrop-blur-lg", {
const processCardVariants = cva("flex border", {
variants: {
variant: {
indigo:
"flex border text-slate-50 border-slate-700 backdrop-blur-lg bg-gradient-to-br from-[rgba(15,23,42,0.7)_40%] to-[#3730a3_120%]",
"text-slate-50 border-slate-700 bg-gradient-to-br from-[rgba(15,23,42,0.7)_40%] to-[#3730a3_120%]",
light: "shadow",
bmp: "flex border text-white border-[rgba(239,191,4,0.25)] bg-[#111D55]",
},
size: {
sm: "min-w-[25%] max-w-[25%]",
md: "min-w-[50%] max-w-[50%]",
lg: "min-w-[75%] max-w-[75%]",
xl: "min-w-full max-w-full",
bmp: "text-white border-[rgba(239,191,4,0.25)] bg-[#111D55]",
},
},
defaultVariants: {
variant: "bmp",
size: "md",
},
})
const subscribeToViewportWidth = (onStoreChange: () => void) => {
window.addEventListener("resize", onStoreChange)
return () => window.removeEventListener("resize", onStoreChange)
export function getActiveTimelineStep(
scrollLeft: number,
maxScroll: number,
stepsLength: number,
) {
if (stepsLength <= 1 || maxScroll <= 0) return 0
const progress = Math.min(1, Math.max(0, scrollLeft / maxScroll))
return Math.round(progress * (stepsLength - 1))
}
const getViewportWidth = () => window.innerWidth
export function getTimelineStepScrollLeft(
index: number,
maxScroll: number,
stepsLength: number,
) {
if (stepsLength <= 1 || maxScroll <= 0) return 0
const getServerViewportWidth = () => 0
function useViewportWidth() {
return React.useSyncExternalStore(
subscribeToViewportWidth,
getViewportWidth,
getServerViewportWidth
)
const safeIndex = Math.min(stepsLength - 1, Math.max(0, index))
return (safeIndex / (stepsLength - 1)) * maxScroll
}
interface ContainerScrollContextValue {
scrollYProgress: MotionValue<number>
export function getTimelineScrollBehavior(prefersReducedMotion: boolean): ScrollBehavior {
return prefersReducedMotion ? "auto" : "smooth"
}
interface ProcessCardProps
extends HTMLMotionProps<"div">,
VariantProps<typeof processCardVariants> {
itemsLength: number
index: number
}
extends React.HTMLAttributes<HTMLLIElement>,
VariantProps<typeof processCardVariants> {}
const ContainerScrollContext = React.createContext<
ContainerScrollContextValue | undefined
>(undefined)
function useContainerScrollContext() {
const context = React.useContext(ContainerScrollContext)
if (!context) {
throw new Error(
"useContainerScrollContext must be used within a ContainerScroll Component"
)
}
return context
}
export const ContainerScroll = ({
children,
className,
style,
...props
}: React.HtmlHTMLAttributes<HTMLDivElement>) => {
const scrollRef = React.useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
target: scrollRef,
offset: ["start start", "end end"],
})
return (
<ContainerScrollContext.Provider value={{ scrollYProgress }}>
<div
ref={scrollRef}
className={cn("relative min-h-[120vh]", className)}
style={{ position: "relative", ...style }}
{...props}
>
{children}
</div>
</ContainerScrollContext.Provider>
)
}
export const ContainerSticky = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("sticky left-0 top-0 w-full overflow-hidden", className)}
{...props}
/>
))
ContainerSticky.displayName = "ContainerSticky"
export const ProcessCard = React.forwardRef<HTMLLIElement, ProcessCardProps>(
({ className, variant, ...props }, ref) => (
<li
ref={ref}
className={cn(processCardVariants({ variant }), className)}
{...props}
/>
),
)
ProcessCard.displayName = "ProcessCard"
export const ProcessCardTitle = React.forwardRef<
HTMLDivElement,
@@ -133,37 +80,3 @@ export const ProcessCardBody = React.forwardRef<
/>
))
ProcessCardBody.displayName = "ProcessCardBody"
export const ProcessCard: React.FC<ProcessCardProps> = ({
className,
style,
variant,
size,
itemsLength,
index,
...props
}) => {
const { scrollYProgress } = useContainerScrollContext()
const start = index / itemsLength
const end = start + 1 / itemsLength
const viewportWidth = useViewportWidth()
const [ref, { width }] = useMeasure()
const x = useTransform(
scrollYProgress,
[start, end],
[viewportWidth, -((width ?? 0) * index) + 64 * index]
)
return (
<motion.div
ref={ref}
style={{
x: index > 0 ? x : 0,
...style,
}}
className={cn(processCardVariants({ variant, size }), className)}
{...props}
/>
)
}
ProcessCard.displayName = "ProcessCard"

View File

@@ -106,7 +106,6 @@ export const homeContent = {
],
awardImageFilename: 'preisuebergabe.jpg',
awardImageAlt: 'Preisverleihung Bayerischer Mittelstandspreis',
awardCaption: 'Preisverleihung 2025',
formEyebrow: 'Direktbewerbung 2026',
formTitle: 'Kostenlos bewerben',
footnote: 'Kostenlos und unverbindlich –',

View File

@@ -1,26 +1,14 @@
import { resolveCmsRelationship, type CmsRouteDoc } from '@/spa/cmsRoute'
import { resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute'
const FALLBACK_YEAR = 2025
const FALLBACK_LIMIT = 8
type FallbackWinner = {
id: string | number
year?: string | number
}
export function resolveHomeWinners(
featured: unknown,
cmsWinners: CmsRouteDoc[],
fallbackWinners: FallbackWinner[],
) {
export function resolveHomeWinners(featured: unknown, cmsWinners: CmsRouteDoc[]) {
const selectedWinners = Array.isArray(featured) ? featured : []
const selectedWinnerDocs = selectedWinners
.map((selected) => resolveCmsRelationship(selected, cmsWinners))
.filter((winner): winner is CmsRouteDoc => Boolean(winner))
if (selectedWinnerDocs.length) return selectedWinnerDocs
if (selectedWinners.length) return resolveCmsRelationships(selectedWinners, cmsWinners)
return (cmsWinners.length ? cmsWinners : fallbackWinners)
return cmsWinners
.filter((winner) => Number(winner.year) === FALLBACK_YEAR)
.slice(0, FALLBACK_LIMIT)
}

View File

@@ -1,4 +1,4 @@
import { resolveCmsRelationship, type CmsRouteDoc } from '@/spa/cmsRoute'
import { resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute'
const hasText = (value: unknown) => typeof value === 'string' && value.trim().length > 0
@@ -6,12 +6,7 @@ export function selectJuryDocs(selection: unknown, docs: CmsRouteDoc[]) {
if (selection === undefined) return docs
if (!Array.isArray(selection)) return []
return selection
.map((entry) => resolveCmsRelationship(entry, docs))
.filter(
(member): member is CmsRouteDoc =>
Boolean(member && docs.some((doc) => String(doc.id) === String(member.id))),
)
return resolveCmsRelationships(selection, docs)
}
export function sortJuryMembersQuoteFirst<T extends object>(members: T[]) {
@@ -23,7 +18,8 @@ export function sortJuryMembersQuoteFirst<T extends object>(members: T[]) {
const quoteOrder = Number(hasText(bSortable.quote)) - Number(hasText(aSortable.quote))
if (quoteOrder !== 0) return quoteOrder
const sortOrder = Number(aSortable.sortOrder ?? a.index) - Number(bSortable.sortOrder ?? b.index)
const sortOrder =
Number(aSortable.sortOrder ?? a.index) - Number(bSortable.sortOrder ?? b.index)
return sortOrder !== 0 ? sortOrder : a.index - b.index
})
.map(({ member }) => member)

View File

@@ -38,6 +38,10 @@ export const netzwerkContent = {
greetingQuote:
'„Der Bayerische Mittelstandspreis steht für das, was Bayern stark macht: Unternehmergeist, Verantwortung und Qualität. Mit großer Freude übernehme ich erneut die Schirmherrschaft für diesen bedeutenden Preis.“',
greetingAttribution: '– Ilse Aigner MdL, Präsidentin des Bayerischen Landtags',
greetingVideoButtonLabel: 'Film abspielen',
greetingVideoTitle: 'Grußwort von Ilse Aigner MdL',
greetingVideoDescription: 'Video-Grußwort der Schirmherrin des Bayerischen Mittelstandspreises.',
greetingVideoCloseLabel: 'Schließen',
},
juryIntro: {
eyebrow: 'Wer entscheidet?',
@@ -61,7 +65,6 @@ export const netzwerkContent = {
description:
'Unabhängige Expertinnen und Experten aus Wirtschaft, Wissenschaft und Verbänden, für einen vollständig unabhängigen Bewertungsprozess.',
chairBadge: 'Jury-Vorsitz',
note: 'Hinweis: Einzelne Persönlichkeiten engagieren sich sowohl in der Jury als auch als Partner des BMP – beide Rollen werden auf dieser Seite getrennt ausgewiesen.',
members: [
{
name: 'Prof. Dr. Klaus Bergmann',
@@ -121,10 +124,8 @@ export const netzwerkContent = {
partners: {
eyebrow: 'Netzwerkqualität',
heading: 'PARTNER & SPONSOREN',
description: 'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Klicken für Details.',
description: 'Ein starkes Netzwerk aus Unternehmen und Medien begleitet den Bayerischen Mittelstandspreis. Die gestaffelten Bänder machen die Gewichtung auf jeder Bildschirmgröße klar.',
detailLabel: 'Details',
premiumLabel: '',
defaultLogoColor: '#111D55',
tiers: [
{ tier: 1, label: 'Große Darstellung', note: '' },
{ tier: 2, label: 'Mittlere Darstellung', note: '' },
@@ -287,10 +288,6 @@ export const netzwerkContent = {
imageFilename: 'networking-innenhof.jpg',
imageAlt: 'BMP Partnernetzwerk',
imageLabel: 'BMP Partnernetzwerk',
formEyebrow: 'Sponsoring 2026',
footnotePrefix: 'Oder:',
footnoteLabel: 'Vereinsmitglied werden',
footnoteUrl: '/mitglied-werden',
benefits: [
{ num: '01', label: 'Markenpräsenz', sub: 'Bühne, Drucksachen, digitale Kanäle' },
{ num: '02', label: 'Top-Entscheider', sub: 'Exklusives Netzwerk-Dinner für Partner' },
@@ -302,13 +299,6 @@ export const netzwerkContent = {
{ label: 'Unternehmen' },
{ label: 'Kontakt' },
],
packages: [
{ value: 'main', title: 'Haupt-Sponsoring', desc: 'Maximale Sichtbarkeit auf allen Kanälen – Bühne, Digital, Print.' },
{ value: 'category', title: 'Kategorie-Partner', desc: 'Namensgebung einer Auszeichnungskategorie – fokussiertes Branding.' },
{ value: 'event', title: 'Event-Partner', desc: 'Präsenz beim exklusiven Gala-Abend in München.' },
],
step1Eyebrow: 'Schritt 1 – Paket wählen',
step1Heading: 'Interesse an',
step2Eyebrow: 'Schritt 2 – Unternehmen',
step2Heading: 'Ihr Unternehmen',
companyLabel: 'Unternehmen *',
@@ -324,7 +314,6 @@ export const netzwerkContent = {
backLabel: 'Zurück',
nextLabel: 'Weiter',
submitLabel: 'Exposé anfordern',
requiredPackageError: 'Bitte wählen Sie ein Paket.',
requiredFieldError: 'Pflichtfeld',
invalidEmailError: 'Ungültige E-Mail',
doneLabel: '✓',

View File

@@ -6,7 +6,7 @@ import HomeWinnersSection, { type HomeWinnersContent } from '@/spa/components/Ho
import TestimonialsSection from '@/spa/components/TestimonialsSection'
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'
import Image from '@/spa/components/ui/UnoptimizedImage'
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
import { mediaAlt, mediaObjectPosition, mediaUrl } from '@/spa/cmsMediaField'
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'
import { useIsMobile } from '@/spa/hooks/useIsMobile'
import { Link } from '@/spa/router'
@@ -82,6 +82,7 @@ function HighlightedText({ text, highlight }: { text: string; highlight: string
const About: React.FC = () => {
const isMobile = useIsMobile()
const isRelevanceStacked = useIsMobile(1024)
const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms
const cmsPages = useCmsCollection('pages')
const hero = { ...aboutContent.hero, ...(cms.hero || {}) }
@@ -159,10 +160,10 @@ const About: React.FC = () => {
</div>
</section>
<section id="was-ist" style={{ position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
<section id="was-ist" data-testid="prize-section" style={{ position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 580 }}>
<div style={{ background: WHITE, padding: isMobile ? '32px 24px 44px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
<div data-testid="prize-layout" data-layout={isRelevanceStacked ? 'stacked' : 'split'} style={{ display: 'grid', gridTemplateColumns: isRelevanceStacked ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 580 }}>
<div data-testid="prize-copy-panel" style={{ background: WHITE, padding: isMobile ? '32px 24px 44px' : isRelevanceStacked ? '56px 48px' : '88px clamp(48px, 5.55vw, 80px)', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isRelevanceStacked ? 2 : 0 }}>
{!isMobile && (
<>
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(prize.eyebrow, aboutContent.prize.eyebrow)}</span>
@@ -177,52 +178,60 @@ const About: React.FC = () => {
{text}
</p>
))}
<div style={{ display: 'flex', order: isMobile ? -1 : 0, borderTop: isMobile ? 'none' : '1px solid #D0D5DD', borderBottom: isMobile ? '1px solid #D0D5DD' : 'none', marginBottom: isMobile ? 26 : 0 }}>
<div data-testid="prize-facts" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', order: isMobile ? -1 : 0, borderTop: isMobile ? 'none' : '1px solid #D0D5DD', borderBottom: isMobile ? '1px solid #D0D5DD' : 'none', marginBottom: isMobile ? 26 : 0 }}>
{fallbackArray(prize.facts, aboutContent.prize.facts).map((f, i, arr) => (
<div key={`${f.value}-${i}`} style={{ flex: 1, padding: '20px 0', borderRight: i < arr.length - 1 ? '1px solid #D0D5DD' : 'none', paddingLeft: 0, paddingRight: i < arr.length - 1 ? 20 : 0 }}>
<div style={{ fontFamily: FF, fontSize: 'clamp(1.4rem, 2vw, 1.8rem)', fontWeight: 900, color: '#101828', letterSpacing: '-0.03em', lineHeight: 1 }}>{fallbackText(f.value, '')}</div>
<div style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, color: 'var(--text-brand-on-light)', textTransform: 'uppercase', letterSpacing: '0.18em', marginTop: 6 }}>{fallbackText(f.label, '')}</div>
<div key={`${f.value}-${i}`} data-testid="prize-fact" style={{ minWidth: 0, padding: isMobile ? '20px 8px' : '20px 16px', borderRight: i < arr.length - 1 ? '1px solid #D0D5DD' : 'none', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
<div data-testid="prize-fact-value" style={{ fontFamily: FF, fontSize: 'clamp(1.4rem, 2vw, 1.8rem)', fontWeight: 900, color: '#101828', letterSpacing: '-0.03em', lineHeight: 1 }}>{fallbackText(f.value, '')}</div>
<div data-testid="prize-fact-label" style={{ maxWidth: '100%', fontFamily: FF, fontSize: isMobile ? 10 : 11, fontWeight: 700, color: 'var(--text-brand-on-light)', textTransform: 'uppercase', letterSpacing: isMobile ? '0.11em' : '0.14em', lineHeight: 1.35, marginTop: 8, overflowWrap: 'anywhere', hyphens: 'auto' }}>{fallbackText(f.label, '')}</div>
</div>
))}
</div>
</div>
<div style={{ position: 'relative', overflow: 'hidden', minHeight: isMobile ? 280 : undefined, order: isMobile ? 1 : 0 }}>
<Image
unoptimized
src={mediaUrl(prize.image, `/images/${aboutContent.prize.imageFilename}`)}
alt={mediaAlt(prize.image, fallbackText(prize.imageAlt, aboutContent.prize.imageAlt))}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center', display: 'block' }}
/>
<div style={{ position: 'absolute', inset: 0, background: isMobile
? 'linear-gradient(to top, rgba(3,9,58,0.94) 0%, rgba(3,9,58,0.55) 38%, rgba(3,9,58,0.12) 72%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)'
: `linear-gradient(to right, ${WHITE} 0%, transparent 25%), linear-gradient(to top, rgba(3,9,58,0.75) 0%, transparent 60%)` }} />
{isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 24px', zIndex: 2 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#8FBEEC', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(prize.eyebrow, aboutContent.prize.eyebrow)}</span>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.6)' }}>
<Lines text={fallbackText(prize.heading, aboutContent.prize.heading)} />
</h2>
</div>
)}
{!isMobile && (
<div style={{ position: 'absolute', bottom: 36, left: 36 }}>
<div style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.22em', textTransform: 'uppercase', color: GOLD, marginBottom: 6 }}>{fallbackText(prize.imageLabel, aboutContent.prize.imageLabel)}</div>
<div style={{ width: 28, height: 2, background: GOLD }} />
</div>
)}
</div>
<figure data-testid="prize-figure" style={{ position: 'relative', overflow: 'hidden', minHeight: isMobile ? 280 : isRelevanceStacked ? 420 : undefined, margin: 0, order: isRelevanceStacked ? 1 : 0, display: isMobile ? 'flex' : 'block', flexDirection: isMobile ? 'column' : undefined }}>
<div data-testid="prize-image-visual" style={{ position: isMobile ? 'relative' : 'absolute', inset: isMobile ? undefined : 0, minHeight: isMobile ? 280 : undefined, overflow: 'hidden' }}>
<Image
unoptimized
data-testid="prize-image"
src={mediaUrl(prize.image, `/images/${aboutContent.prize.imageFilename}`)}
alt={mediaAlt(prize.image, fallbackText(prize.imageAlt, aboutContent.prize.imageAlt))}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: mediaObjectPosition(prize.image), display: 'block' }}
/>
<div style={{ position: 'absolute', inset: 0, background: isMobile
? 'linear-gradient(to top, rgba(3,9,58,0.94) 0%, rgba(3,9,58,0.55) 38%, rgba(3,9,58,0.12) 72%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)'
: `linear-gradient(to right, ${WHITE} 0%, transparent 25%), linear-gradient(to top, rgba(3,9,58,0.58) 0%, transparent 52%)` }} />
{isMobile && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 24px', zIndex: 2 }}>
<span style={{ fontFamily: FF, fontSize: 10, color: '#8FBEEC', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(prize.eyebrow, aboutContent.prize.eyebrow)}</span>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.6)' }}>
<Lines text={fallbackText(prize.heading, aboutContent.prize.heading)} />
</h2>
</div>
)}
</div>
<figcaption data-testid="prize-image-caption" style={{ position: isMobile ? 'relative' : 'absolute', zIndex: 3, bottom: isMobile ? undefined : 32, left: isMobile ? undefined : 32, maxWidth: isMobile ? undefined : 'calc(100% - 64px)', padding: isMobile ? '12px 24px' : '10px 14px', borderLeft: `3px solid ${GOLD}`, background: isMobile ? '#03093A' : 'rgba(3,9,58,0.92)', color: '#fff', fontFamily: FF, fontSize: 11, fontWeight: 700, lineHeight: 1.4, letterSpacing: '0.16em', textTransform: 'uppercase', overflowWrap: 'anywhere', boxShadow: isMobile ? 'none' : '0 8px 24px rgba(3,9,58,0.24)', backdropFilter: isMobile ? undefined : 'blur(4px)' }}>
{fallbackText(prize.imageLabel, aboutContent.prize.imageLabel)}
</figcaption>
</figure>
</div>
</section>
<section id="mittelstand" style={{ overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '45% 55%', minHeight: isMobile ? 'auto' : 580 }}>
<div style={{ position: 'relative', overflow: 'hidden', minHeight: isMobile ? 280 : undefined }}>
<section id="mittelstand" data-testid="relevance-section" style={{ overflow: 'hidden' }}>
<div
data-testid="relevance-layout"
data-layout={isRelevanceStacked ? 'stacked' : 'split'}
style={{ display: 'grid', gridTemplateColumns: isRelevanceStacked ? '1fr' : '55% 45%', minHeight: isRelevanceStacked ? 'auto' : 580 }}
>
<div
data-testid="relevance-image-panel"
style={{ position: 'relative', overflow: 'hidden', minHeight: isRelevanceStacked ? (isMobile ? 280 : 420) : undefined }}
>
<Image
unoptimized
data-testid="relevance-image"
src={mediaUrl(mittelstand.image, `/images/${aboutContent.mittelstand.imageFilename}`)}
alt={mediaAlt(mittelstand.image, fallbackText(mittelstand.imageAlt, aboutContent.mittelstand.imageAlt))}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center', display: 'block' }}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: mediaObjectPosition(mittelstand.image), display: 'block' }}
/>
<div style={{ position: 'absolute', inset: 0, background: isMobile
? 'linear-gradient(to top, rgba(3,9,58,0.94) 0%, rgba(3,9,58,0.55) 38%, rgba(3,9,58,0.12) 72%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)'
@@ -242,21 +251,26 @@ const About: React.FC = () => {
)}
</div>
<div style={{ background: NAVY, padding: isMobile ? '32px 24px 40px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
{!isMobile && (
<>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(mittelstand.eyebrow, aboutContent.mittelstand.eyebrow)}</span>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 20px' }}>
<Lines text={fallbackText(mittelstand.heading, aboutContent.mittelstand.heading)} />
</h2>
</>
)}
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} />
{paragraphs(mittelstand.body, aboutContent.mittelstand.body).map((text, index, arr) => (
<p key={index} className="type-body text-role-inverse-secondary" style={{ maxWidth: 440, marginBottom: index === arr.length - 1 ? 40 : 16 }}>
{text}
</p>
))}
<div
data-testid="relevance-copy-panel"
style={{ background: NAVY, padding: isMobile ? '32px 24px 40px' : isRelevanceStacked ? '56px 48px' : '88px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
>
<div data-testid="relevance-copy" style={{ width: '100%', maxWidth: 520, margin: '0 auto' }}>
{!isMobile && (
<>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(mittelstand.eyebrow, aboutContent.mittelstand.eyebrow)}</span>
<h2 className="type-section-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 20px' }}>
<Lines text={fallbackText(mittelstand.heading, aboutContent.mittelstand.heading)} />
</h2>
</>
)}
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} />
{paragraphs(mittelstand.body, aboutContent.mittelstand.body).map((text, index, arr) => (
<p key={index} className="type-body text-role-inverse-secondary" style={{ marginBottom: index === arr.length - 1 ? (isMobile ? 0 : 40) : 16 }}>
{text}
</p>
))}
</div>
</div>
</div>
</section>

View File

@@ -34,7 +34,6 @@ type HomeHeroCms = {
videoLabel?: string | null;
};
type HomeSectionCms = CmsRecord & {
awardCaption?: string | null;
awardImage?: unknown;
awardImageAlt?: string | null;
benefits?: unknown;
@@ -117,7 +116,7 @@ const isMembershipCta = (cta?: { label?: string; url?: string; to?: string } | n
cta?.label?.trim().toLowerCase() === 'mitglied werden';
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
return <>{text.split(/\r?\n/).map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
}
function partnerLogos(partners: PartnerLogoData[]) {
@@ -269,7 +268,10 @@ const Home: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
<div style={{ width: 'min(820px, calc(100vw - 260px))', overflow: 'hidden', flexShrink: 0 }}>
<PartnerTicker logos={partnerLogos(networkPartners)} />
<PartnerTicker
ariaLabel={fallbackText(hero.partnersLabel, homeContent.hero.partnersLabel)}
logos={partnerLogos(networkPartners)}
/>
</div>
</div>
</div>
@@ -540,7 +542,7 @@ const Home: React.FC = () => {
// ─── StatusSlider ─────────────────────────────────────────────────────────────
function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
export function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
const isMobile = useIsMobile();
if (!data?.phases?.length) return null;
@@ -566,11 +568,12 @@ function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
return (
<section
data-testid="phase-section"
style={{
position: 'relative',
overflow: 'hidden',
height: isMobile ? 'auto' : 480,
minHeight: isMobile ? 360 : 'auto',
height: 'auto',
minHeight: isMobile ? 360 : 480,
}}
>
<StatusSlideCard
@@ -603,7 +606,7 @@ function StatusSlideCard({
<div
style={{
width: '100vw',
height: '100%',
minHeight: isMobile ? 360 : 480,
flexShrink: 0,
position: 'relative',
background: phase.bg,
@@ -627,8 +630,8 @@ function StatusSlideCard({
{/* Headline + body + CTA */}
<div>
<h2 className="type-page-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 24px' }}>
{phase.headline}
<h2 className="type-page-title text-role-inverse" data-testid="phase-headline" style={{ textTransform: 'uppercase', margin: '0 0 24px' }}>
<Lines text={fallbackText(phase.headline, '')} />
</h2>
<p className="type-body text-role-inverse-secondary" style={{ marginBottom: 32, maxWidth: isMobile ? 520 : 760 }}>
{phase.body}

View File

@@ -6,11 +6,19 @@ import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { homeContent } from '@/spa/homeContent';
import { selectJuryDocs, sortJuryMembersQuoteFirst } from '@/spa/juryMembers';
import { netzwerkContent } from '@/spa/netzwerkContent';
import Image from '@/spa/components/ui/UnoptimizedImage'
import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo';
import {
groupSponsorsByTier,
sponsorCardBasis,
sponsorColumns,
sponsorTierNumbers,
sponsorViewport,
type SponsorTierNumber,
type SponsorViewport,
} from '@/spa/sponsorHierarchy';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
@@ -19,22 +27,11 @@ const GOLD = '#EFBF04';
type NetzwerkCms = Omit<Partial<typeof netzwerkContent>, 'hero' | 'jury' | 'sponsoring'> & {
hero?: Partial<typeof netzwerkContent.hero> & { image?: unknown }
patronage?: Partial<typeof netzwerkContent.patronage> & { greetingVideo?: unknown }
jury?: Partial<Omit<typeof netzwerkContent.jury, 'members'>> & { members?: unknown[] | null }
sponsoring?: Partial<typeof netzwerkContent.sponsoring> & { image?: unknown }
}
type HomeVideoCms = {
hero?: {
videoLabel?: string | null
}
videoModal?: {
video?: unknown
title?: string | null
description?: string | null
closeLabel?: string | null
}
}
type PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown }
type JuryMember = (typeof netzwerkContent.jury.members)[number] & {
active?: boolean | null
@@ -63,17 +60,7 @@ type PartnerModalCopy = typeof netzwerkContent.partners.modal
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
const managedImage = (upload: unknown, fallback: unknown) => mediaUrl(upload, fallbackText(fallback, ''))
const deprecatedPartnerDescription = 'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.'
const visiblePartnerDescription = (value: unknown) => {
const text = fallbackText(value, netzwerkContent.partners.description)
return text.trim() === deprecatedPartnerDescription ? netzwerkContent.partners.description : text
}
const juryImageFallback = '/images/preistraeger-placeholder.svg'
const sortedPartners = <T extends PartnerLogoData>(partners: T[]) =>
partners
.map((partner, index) => ({ partner, index }))
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
.map(({ partner }) => partner)
const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
const organization = fallbackText(member.organization, '')
const rawRole = fallbackText(member.role, '')
@@ -237,27 +224,46 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
// ── SponsorCell (tiered) ──────────────────────────────────────────────────────
const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx: number; cols: number; detailLabel: string; onClick: () => void }> = ({ partner, variant, idx, cols, detailLabel, onClick }) => {
const SponsorCell: React.FC<{
partner: Partner
variant: 'lg' | 'md' | 'sm'
columns: number
detailLabel: string
gap: number
viewport: SponsorViewport
onClick: () => void
}> = ({ partner, variant, columns, detailLabel, gap, viewport, onClick }) => {
const [hovered, setHovered] = useState(false);
const isMobileCell = useIsMobile();
const lg = variant === 'lg';
const sm = variant === 'sm';
const lastCol = (idx % cols) === cols - 1;
const logoHeight = isMobileCell ? (lg ? 76 : sm ? 54 : 62) : lg ? 92 : sm ? 62 : 76;
const mobileCell = viewport === 'mobile' || viewport === 'narrow-mobile';
const logoHeight = mobileCell ? (lg ? 96 : sm ? 58 : 76) : lg ? 116 : sm ? 72 : 92;
const basis = sponsorCardBasis(columns, gap);
return (
<div
<button
type="button"
aria-label={`${detailLabel}: ${partner.name}`}
data-testid={`sponsor-card-${partner.id}`}
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
padding: isMobileCell ? '26px 20px' : lg ? '44px 38px 40px' : sm ? '30px 28px' : '36px 32px',
borderRight: !lastCol && !isMobileCell ? '1px solid rgba(3,9,58,0.08)' : 'none',
borderBottom: '1px solid rgba(3,9,58,0.08)',
appearance: 'none',
boxSizing: 'border-box',
width: basis,
flex: `0 0 ${basis}`,
minWidth: 0,
padding: mobileCell ? (sm ? '22px 16px' : '30px 24px') : lg ? '48px 42px 44px' : sm ? '30px 26px' : '38px 34px',
border: '1px solid rgba(3,9,58,0.1)',
borderTop: `${lg ? 4 : sm ? 2 : 3}px solid ${GOLD}`,
display: 'flex', flexDirection: 'column', gap: lg ? 18 : 16,
cursor: 'pointer',
background: hovered ? '#E8E5E4' : 'transparent',
background: hovered ? '#E8E5E4' : 'rgba(255,255,255,0.92)',
transition: 'background 0.15s',
position: 'relative',
textAlign: 'left',
color: 'inherit',
font: 'inherit',
}}
>
<div style={{ height: logoHeight, display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', minWidth: 0 }}>
@@ -269,7 +275,7 @@ const SponsorCell: React.FC<{ partner: Partner; variant: 'lg' | 'md' | 'sm'; idx
<div style={{ fontFamily: FF, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: GOLD, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 4 }}>
{detailLabel} <ArrowRight size={10} />
</div>
</div>
</button>
);
};
@@ -464,15 +470,14 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
const Netzwerk: React.FC = () => {
const isMobile = useIsMobile();
const isTabletOrMobile = useIsMobile(1200);
const isNarrowMobile = useIsMobile(360);
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null);
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
const greetingVideoRef = useRef<HTMLVideoElement>(null);
const cms = (useCmsRoute()?.doc?.netzwerk || {}) as NetzwerkCms;
const homePage = useCmsCollection('pages').find((page) => page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home');
const cmsPartners = useCmsCollection('partners') as unknown as Partner[];
const cmsJuryMembers = useCmsCollection('jury-mitglieder');
const home = (homePage?.home || {}) as HomeVideoCms;
const homeVideoModal = home.videoModal || {};
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
const patronage = { ...netzwerkContent.patronage, ...(cms.patronage || {}) };
const juryIntro = { ...netzwerkContent.juryIntro, ...(cms.juryIntro || {}) };
@@ -483,30 +488,34 @@ const Netzwerk: React.FC = () => {
const sponsoring = { ...netzwerkContent.sponsoring, ...(cms.sponsoring || {}) };
const patronPeople = fallbackArray<PatronPerson>(patronage.people, netzwerkContent.patronage.people);
const cmsJurySelection = cms.jury?.members;
const hasCmsJurySelection = cmsJurySelection !== undefined;
const sourcedJuryDocs = selectJuryDocs(cmsJurySelection, cmsJuryMembers);
const normalizedJuryMembers = sourcedJuryDocs
.map(normalizeJuryMember)
.filter((member) => member.active !== false && member.name);
const jurySourceMembers: JuryMember[] = normalizedJuryMembers.length || hasCmsJurySelection
? normalizedJuryMembers
: netzwerkContent.jury.members;
const juryMembers = sortJuryMembersQuoteFirst(jurySourceMembers);
const juryMembers = sortJuryMembersQuoteFirst(normalizedJuryMembers);
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
const partners = sortedPartners(cmsPartners)
const partners = cmsPartners
.filter((partner) => partner.active !== false)
.map((partner) => ({
...partner,
id: partner.stableId || partner.id,
}));
const partnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
const configuredPartnerTiers = fallbackArray<PartnerTier>(partnerSection.tiers, netzwerkContent.partners.tiers);
const partnerTiers = sponsorTierNumbers.map((tier) => ({
...(netzwerkContent.partners.tiers.find((entry) => entry.tier === tier) as PartnerTier),
...(configuredPartnerTiers.find((entry) => Number(entry.tier) === tier) || {}),
tier,
}));
const sponsorsByTier = groupSponsorsByTier(partners);
const sponsorLayoutViewport = sponsorViewport({ isMobile, isNarrowMobile, isTabletOrMobile });
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits);
const greetingVideoSrc = mediaUrl(homeVideoModal.video, '');
const greetingVideoTitle = fallbackText(homeVideoModal.title, homeContent.videoModal.title);
const greetingVideoLabel = fallbackText(home.hero?.videoLabel, homeContent.hero.videoLabel);
const greetingVideoCloseLabel = fallbackText(homeVideoModal.closeLabel, homeContent.videoModal.closeLabel);
const greetingVideoSrc = mediaUrl(patronage.greetingVideo, '');
const greetingVideoTitle = fallbackText(patronage.greetingVideoTitle, netzwerkContent.patronage.greetingVideoTitle);
const greetingVideoDescription = fallbackText(patronage.greetingVideoDescription, netzwerkContent.patronage.greetingVideoDescription);
const greetingVideoLabel = fallbackText(patronage.greetingVideoButtonLabel, netzwerkContent.patronage.greetingVideoButtonLabel);
const greetingVideoCloseLabel = fallbackText(patronage.greetingVideoCloseLabel, netzwerkContent.patronage.greetingVideoCloseLabel);
useEffect(() => {
if (!showGreetingVideo || !greetingVideoSrc) return;
@@ -759,23 +768,61 @@ const Netzwerk: React.FC = () => {
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}><Lines text={fallbackText(partnerSection.heading, netzwerkContent.partners.heading)} /></h2>
</div>
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
{visiblePartnerDescription(partnerSection.description)}
{fallbackText(partnerSection.description, netzwerkContent.partners.description)}
</p>
</div>
{/* Tiered grid – continuous, ranks marked by label bars */}
<div style={{ position: 'relative', zIndex: 1 }}>
{/* Independent tier bands keep rank legible without mixing tiers to fill a row. */}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 52px' : '24px 48px 80px' }}>
{partnerTiers.map(t => {
const tier = Number(t.tier) as 1 | 2 | 3;
const tier = Number(t.tier) as SponsorTierNumber;
const variant = tier === 1 ? 'lg' as const : tier === 2 ? 'md' as const : 'sm' as const;
const cols = isMobile ? (tier === 1 ? 1 : 2) : (tier === 3 ? 4 : 2);
const items = partners.filter(p => p.tier === tier);
const columns = sponsorColumns(tier, sponsorLayoutViewport);
const gap = isMobile ? 12 : tier === 3 ? 16 : 20;
const items = sponsorsByTier[tier];
const label = fallbackText(t.label, netzwerkContent.partners.tiers[tier - 1]?.label || '');
const note = fallbackText(t.note, '');
if (!items.length) return null;
return (
<div key={t.tier} style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
{items.map((p, i) => (
<SponsorCell key={p.id} partner={p} variant={variant} idx={i} cols={cols} detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)} onClick={() => setSelectedPartner(p)} />
))}
</div>
<section
key={tier}
data-testid={`sponsor-tier-${tier}`}
data-tier={tier}
data-columns={columns}
data-viewport={sponsorLayoutViewport}
style={{
maxWidth: 1240,
margin: '0 auto',
padding: isMobile ? (tier === 1 ? '34px 0 14px' : '42px 0 14px') : (tier === 1 ? '50px 0 18px' : '62px 0 18px'),
borderBottom: tier === 3 ? 'none' : '1px solid rgba(3,9,58,0.08)',
}}
>
<header style={{ maxWidth: tier === 1 ? 720 : 620, margin: '0 auto', textAlign: 'center', padding: isMobile ? '0 8px 24px' : '0 16px 32px' }}>
<h3 style={{ fontFamily: FF, fontSize: isMobile ? (tier === 1 ? 25 : 21) : (tier === 1 ? 34 : tier === 2 ? 28 : 23), fontWeight: 800, color: NAVY, textTransform: 'uppercase', letterSpacing: tier === 3 ? '0.08em' : '0.04em', lineHeight: 1.1, margin: 0 }}>
{label}
</h3>
{note ? (
<p style={{ fontFamily: FB, fontSize: isMobile ? 14 : 16, color: 'rgba(16,24,40,0.58)', lineHeight: 1.55, margin: '12px 0 0' }}>{note}</p>
) : null}
</header>
<div
data-testid={`sponsor-tier-${tier}-cards`}
style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'stretch', gap }}
>
{items.map((partner) => (
<SponsorCell
key={partner.id}
partner={partner}
variant={variant}
columns={columns}
detailLabel={fallbackText(partnerSection.detailLabel, netzwerkContent.partners.detailLabel)}
gap={gap}
viewport={sponsorLayoutViewport}
onClick={() => setSelectedPartner(partner)}
/>
))}
</div>
</section>
);
})}
</div>
@@ -854,6 +901,10 @@ const Netzwerk: React.FC = () => {
{showGreetingVideo && greetingVideoSrc && (
<div
onClick={(e) => { if (e.target === e.currentTarget) setShowGreetingVideo(false); }}
role="dialog"
aria-modal="true"
aria-labelledby="netzwerk-greeting-video-title"
aria-describedby="netzwerk-greeting-video-description"
style={{
position: 'fixed',
inset: 0,
@@ -865,6 +916,8 @@ const Netzwerk: React.FC = () => {
padding: isMobile ? 16 : 24,
}}
>
<h2 id="netzwerk-greeting-video-title" className="sr-only">{greetingVideoTitle}</h2>
<p id="netzwerk-greeting-video-description" className="sr-only">{greetingVideoDescription}</p>
<button
type="button"
onClick={() => setShowGreetingVideo(false)}

View File

@@ -49,6 +49,7 @@ function Lines({ text }: { text: string }) {
const Participation: React.FC = () => {
const isMobile = useIsMobile();
const isEligibilityCompact = useIsMobile(900);
const qualSectionRef = useRef<HTMLElement>(null);
const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms;
const hero = { ...participationContent.hero, ...(cms.hero || {}) };
@@ -94,10 +95,10 @@ const Participation: React.FC = () => {
<WegZurAuszeichnungSection content={process} />
{/* ── QUALIFICATIONS ───────────────────────────────────────────────────── */}
<section id="voraussetzungen" ref={qualSectionRef} style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<section id="voraussetzungen" ref={qualSectionRef} data-testid="eligibility-section" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate', scrollMarginTop: 60 }}>
{/* Header */}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: isMobile ? 24 : 48, alignItems: 'flex-end', borderBottom: `1px solid #D0D5DD` }}>
<div data-testid="eligibility-header" style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 36px' : '80px 80px 56px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: isMobile ? 24 : 48, alignItems: 'flex-end', background: '#fff', borderBottom: '1px solid rgba(17,29,85,0.16)' }}>
<div>
<span style={{ fontFamily: FF, fontSize: 10, color: 'var(--text-eyebrow-light)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(eligibility.eyebrow, participationContent.eligibility.eyebrow)}</span>
<h2 className="type-section-title text-role-heading" style={{ textTransform: 'uppercase', margin: 0 }}>
@@ -122,56 +123,60 @@ const Participation: React.FC = () => {
</div>
{/* 4 criteria as editorial rows */}
<div style={{ position: 'relative', zIndex: 1, background: '#fff', borderBottom: `1px solid #D0D5DD` }}>
{eligibilityCriteria.map((item, i, arr) => {
const Icon = ICONS[item.icon as keyof typeof ICONS] || MapPin;
return (
<QualRow key={i} item={item} Icon={Icon} last={i === arr.length - 1} />
);
})}
<div data-testid="eligibility-criteria-group" style={{ position: 'relative', zIndex: 1, background: '#F4F6F8', borderBottom: '1px solid rgba(17,29,85,0.16)', boxShadow: 'inset 0 1px 0 rgba(17,29,85,0.04)' }}>
<ol data-testid="eligibility-criteria" style={{ listStyle: 'none', maxWidth: 1280, margin: '0 auto', padding: isEligibilityCompact ? '8px 0' : '16px 80px', boxSizing: 'border-box' }}>
{eligibilityCriteria.map((item, i, arr) => {
const Icon = ICONS[item.icon as keyof typeof ICONS] || MapPin;
return (
<QualRow key={i} item={item} Icon={Icon} last={i === arr.length - 1} index={i} compact={isEligibilityCompact} />
);
})}
</ol>
</div>
{/* Zusätzliche Hinweise: Verbund + Wiederbewerber */}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '28px 24px' : '40px 80px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: isMobile ? 24 : 48, borderBottom: '1px solid #D0D5DD' }}>
{eligibilityNotes.map((note, i) => {
const Icon = ICONS[note.icon as keyof typeof ICONS] || Building2;
return (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
<Icon size={18} style={{ color: GOLD, flexShrink: 0, marginTop: 3 }} strokeWidth={1.7} />
<div>
<div style={{ fontFamily: FF, fontSize: 13, fontWeight: 700, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>{fallbackText(note.title, '')}</div>
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
{fallbackText(note.body, '')}
</p>
</div>
<div data-testid="eligibility-notes-group" style={{ position: 'relative', zIndex: 1, background: '#fff', borderBottom: '1px solid rgba(17,29,85,0.16)' }}>
<div style={{ maxWidth: 1280, margin: '0 auto', padding: isEligibilityCompact ? '12px 24px' : '40px 80px 44px', display: 'grid', gridTemplateColumns: isEligibilityCompact ? '1fr' : 'repeat(2, minmax(0, 1fr))', gap: isEligibilityCompact ? 0 : 48, boxSizing: 'border-box' }}>
{eligibilityNotes.map((note, i) => {
const Icon = ICONS[note.icon as keyof typeof ICONS] || Building2;
return (
<div key={i} data-testid="eligibility-note" data-order={i + 1} style={{ display: 'flex', alignItems: 'flex-start', gap: 14, padding: isEligibilityCompact ? '20px 0' : '18px 0 0', borderTop: '2px solid rgba(239,191,4,0.72)', borderBottom: isEligibilityCompact && i < eligibilityNotes.length - 1 ? '1px solid rgba(17,29,85,0.12)' : 'none' }}>
<Icon aria-hidden="true" size={18} style={{ color: GOLD, flexShrink: 0, marginTop: 3 }} strokeWidth={1.7} />
<div>
<div style={{ fontFamily: FF, fontSize: 13, fontWeight: 700, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>{fallbackText(note.title, '')}</div>
<p className="type-body text-role-secondary" style={{ margin: 0 }}>
{fallbackText(note.body, '')}
</p>
</div>
</div>
);
})}
</div>
);
})}
</div>
{/* CTA nudge */}
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '24px 24px' : '32px 80px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<CheckCircle2 size={18} style={{ color: GOLD, flexShrink: 0 }} />
<span style={{ fontFamily: FF, fontSize: 18, fontWeight: 600, color: '#101828' }}>{fallbackText(eligibility.nudgeText, participationContent.eligibility.nudgeText)}</span>
<aside data-testid="eligibility-summary" style={{ position: 'relative', zIndex: 1, padding: isEligibilityCompact ? '24px' : '28px 80px', background: NAVY, boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.08)' }}>
<div style={{ maxWidth: 1120, margin: '0 auto', display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<CheckCircle2 aria-hidden="true" size={19} style={{ color: GOLD, flexShrink: 0, marginTop: 3 }} />
<span style={{ fontFamily: FF, fontSize: isMobile ? 17 : 18, fontWeight: 600, color: '#fff', lineHeight: 1.55 }}>{fallbackText(eligibility.nudgeText, participationContent.eligibility.nudgeText)}</span>
</div>
</div>
</aside>
</section>
{/* ── BEWERBUNGSWEGE ───────────────────────────────────────────────────── */}
<BewerbungswegeSection content={applicationWays} />
{/* ── DATES BANNER ─────────────────────────────────────────────────────── */}
<section id="fristen" style={{ background: GOLD }}>
<section id="fristen" data-testid="important-dates-transition" style={{ background: GOLD, borderTop: `${isMobile ? 5 : 8}px solid ${NAVY}`, scrollMarginTop: 80, boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.24)' }}>
{isMobile ? (
/* Mobile: slim horizontal bar */
<div style={{ padding: '24px 22px 26px' }}>
<div style={{ fontFamily: FF, fontSize: 12, fontWeight: 700, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.2em', marginBottom: 18 }}>{fallbackText(datesBanner.heading, participationContent.datesBanner.heading)}</div>
<div style={{ display: 'flex' }}>
<div data-testid="important-dates-list" style={{ display: 'grid' }}>
{mobileDates.map((d, i) => (
<div key={i} style={{ flex: 1, minWidth: 0, paddingLeft: i > 0 ? 14 : 0, borderLeft: i > 0 ? '1px solid rgba(3,9,58,0.2)' : 'none' }}>
<div style={{ fontFamily: FF, fontSize: 17, fontWeight: 900, color: '#101828', letterSpacing: '-0.02em', lineHeight: 1.12, marginBottom: 6 }}>{fallbackText(d.date, '')}</div>
<div style={{ fontFamily: FF, fontSize: 9.5, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.55)', lineHeight: 1.25 }}>{fallbackText(d.label, '')}</div>
<div key={i} data-testid="important-date-item" data-order={i + 1} style={{ display: 'grid', gridTemplateColumns: 'minmax(112px, 0.9fr) minmax(0, 1.1fr)', gap: 16, minWidth: 0, padding: '14px 0', borderTop: '1px solid rgba(3,9,58,0.2)', alignItems: 'center' }}>
<div style={{ fontFamily: FF, fontSize: 16, fontWeight: 900, color: '#101828', letterSpacing: '-0.02em', lineHeight: 1.12 }}>{fallbackText(d.date, '')}</div>
<div style={{ fontFamily: FF, fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.72)', lineHeight: 1.35, overflowWrap: 'anywhere' }}>{fallbackText(d.label, '')}</div>
</div>
))}
</div>
@@ -188,7 +193,7 @@ const Participation: React.FC = () => {
<React.Fragment key={i}>
<div style={{ padding: '48px 40px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<div style={{ fontFamily: FF, fontSize: 'clamp(1.1rem, 1.8vw, 1.4rem)', fontWeight: 900, color: '#101828', letterSpacing: '-0.02em', marginBottom: 6 }}>{fallbackText(d.date, '')}</div>
<div style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.55)' }}>{fallbackText(d.label, '')}</div>
<div style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'rgba(16,24,40,0.72)' }}>{fallbackText(d.label, '')}</div>
</div>
{i < desktopDates.length - 1 && <div style={{ background: 'rgba(3,9,58,0.12)' }} />}
</React.Fragment>
@@ -207,31 +212,32 @@ const Participation: React.FC = () => {
// ── QualRow ───────────────────────────────────────────────────────────────────
function QualRow({ item, Icon, last }: { item: { num: string; title: string; body: string }; Icon: React.ElementType; last: boolean }) {
function QualRow({ item, Icon, last, index, compact }: { item: { num: string; title: string; body: string }; Icon: React.ElementType; last: boolean; index: number; compact: boolean }) {
const [hovered, setHovered] = React.useState(false);
const isMobileRow = useIsMobile();
return (
<div
style={{ display: 'grid', gridTemplateColumns: isMobileRow ? '40px 1fr' : '80px 200px 1fr 48px', gap: isMobileRow ? '0 16px' : '0 32px', padding: isMobileRow ? '24px 24px' : '28px 80px', borderBottom: last ? 'none' : '1px solid #D0D5DD', alignItems: isMobileRow ? 'flex-start' : 'center', background: hovered ? '#EAE7E6' : 'transparent', transition: 'background 0.15s', cursor: 'default' }}
<li
data-testid="eligibility-criterion"
data-order={index + 1}
style={{ display: 'grid', gridTemplateColumns: compact ? '40px 1fr' : '64px minmax(180px, 220px) minmax(0, 1fr) 44px', gap: compact ? '0 16px' : '0 28px', padding: compact ? '22px 24px' : '26px 0', borderBottom: last ? 'none' : '1px solid rgba(17,29,85,0.14)', alignItems: compact ? 'flex-start' : 'center', background: hovered ? '#fff' : 'transparent', transition: 'background 0.15s', cursor: 'default' }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: hovered ? GOLD : 'rgba(239,191,4,0.5)', letterSpacing: '0.1em', transition: 'color 0.2s', paddingTop: 2 }}>{item.num}</span>
{isMobileRow ? (
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: hovered ? '#715600' : '#5B6475', letterSpacing: '0.1em', transition: 'color 0.2s', paddingTop: 2 }}>{item.num}</span>
{compact ? (
<div>
<div style={{ fontFamily: FF, fontSize: 17, fontWeight: 700, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 4 }}>{item.title}</div>
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.5)', lineHeight: 1.6 }}>{item.body}</div>
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.72)', lineHeight: 1.6 }}>{item.body}</div>
</div>
) : (
<>
<div style={{ fontFamily: FF, fontSize: 17, fontWeight: 700, color: '#101828', textTransform: 'uppercase', letterSpacing: '0.07em' }}>{item.title}</div>
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.5)', lineHeight: 1.6 }}>{item.body}</div>
<div style={{ fontFamily: FB, fontSize: 17, color: 'rgba(16,24,40,0.72)', lineHeight: 1.6 }}>{item.body}</div>
<div style={{ width: 36, height: 36, background: hovered ? GOLD : 'rgba(3,9,58,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background 0.2s' }}>
<Icon size={16} style={{ color: hovered ? '#101828' : 'rgba(16,24,40,0.3)', transition: 'color 0.2s' }} strokeWidth={1.5} />
</div>
</>
)}
</div>
</li>
);
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
import { Link } from '@/spa/router';
import { Trophy, ArrowRight, Medal } from 'lucide-react';
import { WINNERS, type Winner } from '@/spa/data/winners';
import type { Winner } from '@/spa/data/winners';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
@@ -151,8 +151,7 @@ export default function Preistraeger() {
);
const winners = useMemo(() => {
const fallbackWinner = card.fallbackWinner || preistraegerIndexContent.card.fallbackWinner;
if (cmsPreistraeger.length) return cmsPreistraeger.map((doc) => normalizeWinner(doc, placeholderImage, fallbackWinner)).filter((winner) => winner.slug);
return WINNERS;
return cmsPreistraeger.map((doc) => normalizeWinner(doc, placeholderImage, fallbackWinner)).filter((winner) => winner.slug);
}, [card, cmsPreistraeger, placeholderImage]);
const periods = useMemo<WinnerPeriod[]>(() => {
const annualYears = [...new Set(

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { Link, useParams, Navigate } from '@/spa/router';
import { ArrowLeft, Globe, ChevronRight, Download, ChevronLeft, X } from 'lucide-react';
import { WINNERS } from '@/spa/data/winners';
import { useCmsRoute } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
@@ -79,7 +78,7 @@ export default function PreistraegerDetail() {
website: String(cmsWinner.website || '#'),
hasMedia: Boolean(cmsWinner.hasMedia),
detailPage: (cmsWinner.detailPage || {}) as PreistraegerDetailCms,
} : WINNERS.find(w => w.slug === slug);
} : undefined;
const isMobile = useIsMobile();
const detail = (winner && 'detailPage' in winner ? { ...preistraegerDetailContent, ...(winner.detailPage || {}) } : cmsDetail) as PreistraegerDetailCms;

View File

@@ -2,9 +2,15 @@ import React from 'react';
import { Calendar, MapPin } from 'lucide-react';
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import Image from '@/spa/components/ui/UnoptimizedImage'
import {
pressEventsFromCms,
pressNewsFromCms,
type PressEventItem,
type PressNewsItem,
} from '@/spa/pressCollectionItems';
import { pressIndexContent } from '@/spa/pressIndexContent';
import { Link } from '@/spa/router';
@@ -15,14 +21,11 @@ const FB = '"Inter", sans-serif';
type PressIndexCms = Partial<typeof pressIndexContent> & {
hero?: Partial<typeof pressIndexContent.hero> & { image?: unknown }
events?: Partial<typeof pressIndexContent.events> & { collectionFallbackImage?: unknown; fallbackItems?: PressEventItem[] }
news?: Partial<typeof pressIndexContent.news> & { collectionFallbackImage?: unknown; fallbackItems?: PressNewsItem[] }
downloads?: Partial<typeof pressIndexContent.downloads> & { kitFile?: unknown }
events?: Partial<typeof pressIndexContent.events> & { collectionFallbackImage?: unknown }
news?: Partial<typeof pressIndexContent.news> & { collectionFallbackImage?: unknown }
downloads?: Partial<typeof pressIndexContent.downloads> & { contactImage?: unknown }
}
type PressEventItem = (typeof pressIndexContent.events.fallbackItems)[number] & { image?: unknown }
type PressNewsItem = (typeof pressIndexContent.news.fallbackItems)[number] & { image?: unknown }
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
const fallbackArray = <T,>(value: unknown, fallback: T[]) => Array.isArray(value) && value.length ? value as T[] : fallback;
@@ -39,43 +42,6 @@ const statusLabelMap = (items: unknown) =>
return labels
}, {})
const normalizeStatus = (status: unknown, labels: Record<string, string>, defaultStatusLabel: string) => {
const value = fallbackText(status, defaultStatusLabel)
return labels[value] || value
}
const detailPath = (spaPath: unknown, slug: unknown, prefix: '/presse/events' | '/presse/blog') => {
const path = fallbackText(spaPath, '')
if (path.startsWith(`${prefix}/`)) return path
const pathSlug = fallbackText(slug, '').replace(/^\/+/, '')
return pathSlug ? `${prefix}/${pathSlug}` : prefix
}
const eventFromCms = (
event: CmsRouteDoc,
fallbackImage: string,
labels: Record<string, string>,
eventsSection: NonNullable<PressIndexCms['events']>,
): PressEventItem => ({
title: String(event.title || fallbackText(eventsSection.missingTitleLabel, pressIndexContent.events.missingTitleLabel)),
date: String(event.date || fallbackText(eventsSection.missingDateLabel, pressIndexContent.events.missingDateLabel)),
location: String(event.location || event.venue || fallbackText(eventsSection.missingLocationLabel, pressIndexContent.events.missingLocationLabel)),
cat: String(event.category || fallbackText(eventsSection.missingCategoryLabel, pressIndexContent.events.missingCategoryLabel)),
status: normalizeStatus(event.status, labels, fallbackText(eventsSection.defaultStatusLabel, pressIndexContent.events.defaultStatusLabel)),
img: docImageUrl(event, fallbackImage),
desc: String(event.description || event.meta?.description || ''),
slug: detailPath(event.spaPath, event.slug, '/presse/events'),
})
const postFromCms = (post: CmsRouteDoc, fallbackImage: string, newsSection: NonNullable<PressIndexCms['news']>): PressNewsItem => ({
title: String(post.title || fallbackText(newsSection.missingTitleLabel, pressIndexContent.news.missingTitleLabel)),
excerpt: String(post.excerpt || post.meta?.description || ''),
cat: String(post.cat || fallbackText(newsSection.missingCategoryLabel, pressIndexContent.news.missingCategoryLabel)),
img: docImageUrl(post, fallbackImage, 'heroImage'),
slug: detailPath(post.spaPath, post.slug, '/presse/blog'),
})
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>
}
@@ -180,28 +146,23 @@ const Press: React.FC = () => {
const newsSection = React.useMemo(() => ({ ...pressIndexContent.news, ...(cms.news || {}) }), [cms.news]);
const downloads = React.useMemo(() => ({ ...pressIndexContent.downloads, ...(cms.downloads || {}) }), [cms.downloads]);
const contactLines = fallbackText(downloads.contactLines, pressIndexContent.downloads.contactLines).split('\n');
const contactImageUrl = mediaUrl(downloads.contactImage, '');
const contactImageAlt = typeof downloads.contactImageAlt === 'string'
? downloads.contactImageAlt
: mediaAlt(downloads.contactImage, '');
const cmsEvents = useCmsCollection('events');
const cmsPosts = useCmsCollection('posts');
const eventStatusLabels = React.useMemo(() => statusLabelMap(eventsSection.statusLabels), [eventsSection.statusLabels]);
const eventCollectionFallbackImage = mediaUrl(eventsSection.collectionFallbackImage, `/images/${pressIndexContent.events.collectionFallbackImageFilename}`);
const newsCollectionFallbackImage = mediaUrl(newsSection.collectionFallbackImage, `/images/${pressIndexContent.news.collectionFallbackImageFilename}`);
const combinedEvents = React.useMemo(() => {
if (cmsEvents.length) {
return cmsEvents.map((event) =>
eventFromCms(
event,
eventCollectionFallbackImage,
eventStatusLabels,
eventsSection,
),
);
}
return fallbackArray<PressEventItem>(eventsSection.fallbackItems, pressIndexContent.events.fallbackItems);
}, [cmsEvents, eventCollectionFallbackImage, eventStatusLabels, eventsSection]);
const combinedNews = React.useMemo(() => {
if (cmsPosts.length) return cmsPosts.map((post) => postFromCms(post, newsCollectionFallbackImage, newsSection));
return fallbackArray<PressNewsItem>(newsSection.fallbackItems, pressIndexContent.news.fallbackItems);
}, [cmsPosts, newsCollectionFallbackImage, newsSection]);
const combinedEvents = React.useMemo(
() => pressEventsFromCms(cmsEvents, eventCollectionFallbackImage, eventStatusLabels, eventsSection),
[cmsEvents, eventCollectionFallbackImage, eventStatusLabels, eventsSection],
);
const combinedNews = React.useMemo(
() => pressNewsFromCms(cmsPosts, newsCollectionFallbackImage, newsSection),
[cmsPosts, newsCollectionFallbackImage, newsSection],
);
return (
<div className="animate-fade-in">
@@ -428,13 +389,21 @@ const Press: React.FC = () => {
</div>
</section>
{/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */}
{/* ── 4. PRESSE-MATERIAL & KONTAKT ────────────────────────────────── */}
<section id="downloads" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: '1fr', minHeight: isMobile ? 'auto' : 600 }}>
<div
data-testid="press-contact-layout"
data-layout={contactImageUrl ? (isMobile ? 'stacked' : 'two-column') : 'copy-only'}
style={{
display: 'grid',
gridTemplateColumns: isMobile || !contactImageUrl ? '1fr' : 'minmax(0, 1fr) minmax(320px, 1fr)',
}}
>
{/* Left col */}
{/* Copy comes first in the DOM and visually, including in the mobile stack. */}
<div
data-testid="press-contact-copy"
style={{
background: '#fff',
padding: isMobile ? '48px 24px' : '88px 80px',
@@ -524,6 +493,25 @@ const Press: React.FC = () => {
</div>
</div>
</div>
{contactImageUrl && (
<div
data-testid="press-contact-image"
style={{
minHeight: isMobile ? 320 : 560,
position: 'relative',
overflow: 'hidden',
background: NAVY,
}}
>
<Image
unoptimized
src={contactImageUrl}
alt={contactImageAlt}
style={{ width: '100%', height: '100%', position: 'absolute', inset: 0, objectFit: 'cover', display: 'block' }}
/>
</div>
)}
</div>
</section>
</div>

View File

@@ -51,7 +51,6 @@ export const participationContent = {
eyebrow: 'Berechtigung',
heading: 'WER KANN\nTEILNEHMEN?',
primaryCta: { label: 'Jetzt bewerben', url: '#bewerben' },
secondaryCta: { label: 'Mitglied werden', url: '/mitglied-werden' },
description:
'Vier klar definierte Kriterien: Erfüllen Sie alle, bewerben Sie sich kostenlos und ohne bürokratischen Aufwand.',
criteria: [
@@ -74,7 +73,6 @@ export const participationContent = {
},
],
nudgeText: 'Alle 4 Punkte erfüllt? Dann jetzt kostenlos bewerben.',
nudgeCta: { label: 'Zum Formular', url: '#bewerben' },
},
applicationWays: {
eyebrow: 'Bewerbung 2026',
@@ -90,15 +88,12 @@ export const participationContent = {
'Mitglieder des Wirtschaftsbeirates Bayern',
'Ehemalige Preisträger des Bayerischen Mittelstandspreises',
].map((text) => ({ text })),
fallbackCta: { label: 'Zum Formular', url: '#bewerben' },
ways: [
{
label: 'Weg 01',
title: 'Vorschlag\nunterbreiten',
desc: 'Durch ehemalige Preisträger, die HAM, Senatoren und den Preis unterstützende Institutionen.',
icon: 'send',
featured: false,
cta: { label: 'Vorschlag unterbreiten', url: '#bewerben' },
listType: 'institutions',
facts: [],
},
@@ -107,8 +102,6 @@ export const participationContent = {
title: 'Initiativ­bewerbung',
desc: 'Für Unternehmer, die selbst die Initiative ergreifen.',
icon: 'userCheck',
featured: false,
cta: { label: 'Jetzt bewerben', url: '#bewerben' },
listType: 'facts',
facts: [
{ label: '0 EUR', desc: 'Teilnahmegebühr' },
@@ -118,7 +111,6 @@ export const participationContent = {
],
},
],
recommendedLabel: 'Empfohlen',
},
datesBanner: {
heading: 'Wichtige Termine 2026',
@@ -135,25 +127,6 @@ export const participationContent = {
],
},
awardsGrid: participationAwardsGrid,
applicationForm: {
imageFilename: 'preistraeger-jubel.jpg',
imageAlt: 'Preisverleihung 2025',
imageCaption: 'Preisverleihung 2025',
eyebrow: 'Direktbewerbung',
heading: 'BEWERBEN ODER\nVORSCHLAGEN.',
description:
'Sie können sich selbst bewerben oder ein herausragendes Unternehmen vorschlagen. Firmenverbunde können sich ebenfalls bewerben. Die Teilnahme ist vollständig kostenfrei.',
facts: [
{ num: '01', label: '0 EUR', desc: 'Teilnahmegebühr' },
{ num: '02', label: 'Schnell', desc: 'Ca. 30–45 Minuten' },
{ num: '03', label: 'Mehrstufig', desc: 'Transparenter Prozess' },
],
formEyebrow: 'Bewerbung 2026',
formTitle: 'Kostenlos bewerben',
uploadCta: { label: 'PDF hochladen', url: '/formular-hochladen' },
uploadPrompt: 'Sie haben Ihre Unterlagen bereits als PDF vorbereitet? Laden Sie sie direkt hoch, statt das Formular auszufüllen.',
uploadFooterCta: { label: 'Formular hochladen →', url: '/formular-hochladen' },
},
form: {
stepCompleteLabel: '✓',
backLabel: 'Zurück',

View File

@@ -0,0 +1,102 @@
import { docImageUrl } from '@/spa/cmsMediaField'
import type { CmsRouteDoc } from '@/spa/cmsRoute'
import { pressIndexContent } from '@/spa/pressIndexContent'
export type PressEventItem = {
title: string
date: string
location: string
cat: string
status: string
img: string
desc: string
slug: string
image?: unknown
}
export type PressNewsItem = {
title: string
excerpt: string
cat: string
img: string
slug: string
image?: unknown
}
type PressEventsSection = Partial<typeof pressIndexContent.events>
type PressNewsSection = Partial<typeof pressIndexContent.news>
const fallbackText = (value: unknown, fallback: string) =>
typeof value === 'string' && value.length > 0 ? value : fallback
const normalizeStatus = (
status: unknown,
labels: Record<string, string>,
defaultStatusLabel: string,
) => {
const value = fallbackText(status, defaultStatusLabel)
return labels[value] || value
}
const detailPath = (spaPath: unknown, slug: unknown, prefix: '/presse/events' | '/presse/blog') => {
const path = fallbackText(spaPath, '')
if (path.startsWith(`${prefix}/`)) return path
const pathSlug = fallbackText(slug, '').replace(/^\/+/, '')
return pathSlug ? `${prefix}/${pathSlug}` : prefix
}
export function pressEventsFromCms(
events: CmsRouteDoc[],
fallbackImage: string,
labels: Record<string, string>,
section: PressEventsSection,
): PressEventItem[] {
return events.map((event) => ({
title: String(
event.title ||
fallbackText(section.missingTitleLabel, pressIndexContent.events.missingTitleLabel),
),
date: String(
event.date ||
fallbackText(section.missingDateLabel, pressIndexContent.events.missingDateLabel),
),
location: String(
event.location ||
event.venue ||
fallbackText(section.missingLocationLabel, pressIndexContent.events.missingLocationLabel),
),
cat: String(
event.category ||
fallbackText(section.missingCategoryLabel, pressIndexContent.events.missingCategoryLabel),
),
status: normalizeStatus(
event.status,
labels,
fallbackText(section.defaultStatusLabel, pressIndexContent.events.defaultStatusLabel),
),
img: docImageUrl(event, fallbackImage),
desc: String(event.description || event.meta?.description || ''),
slug: detailPath(event.spaPath, event.slug, '/presse/events'),
}))
}
export function pressNewsFromCms(
posts: CmsRouteDoc[],
fallbackImage: string,
section: PressNewsSection,
): PressNewsItem[] {
return posts.map((post) => ({
title: String(
post.title ||
fallbackText(section.missingTitleLabel, pressIndexContent.news.missingTitleLabel),
),
excerpt: String(post.excerpt || post.meta?.description || ''),
cat: String(
post.cat ||
fallbackText(section.missingCategoryLabel, pressIndexContent.news.missingCategoryLabel),
),
img: docImageUrl(post, fallbackImage, 'heroImage'),
slug: detailPath(post.spaPath, post.slug, '/presse/blog'),
}))
}

View File

@@ -10,7 +10,6 @@ export const pressIndexContent = {
eyebrow: 'Termine 2026',
heading: 'EVENTS RUND UM DEN PREIS.',
description: 'Von der Jurysitzung bis zur festlichen Gala – alle Veranstaltungen auf einen Blick.',
detailLabel: 'Details',
defaultStatusLabel: 'Geplant',
openStatusLabel: 'Anmeldung offen',
missingTitleLabel: 'Event',
@@ -24,92 +23,22 @@ export const pressIndexContent = {
{ value: 'intern', label: 'Intern' },
{ value: 'abgeschlossen', label: 'Abgeschlossen' },
],
fallbackItems: [
{
title: 'Preisverleihung 2026',
date: '22. Okt 2026',
location: 'München, Residenz',
cat: 'Gala / Event',
status: 'In Planung',
img: '/images/buehne-moderatoren.jpg',
desc: 'Der glanzvolle Höhepunkt des Jahres. Die Verleihung der Preise in der prachtvollen Kulisse der Residenz München.',
slug: '/presse/events/preisverleihung-2026',
},
{
title: 'Mittelstands-Gipfel',
date: '15. Juni 2026',
location: 'Nürnberg',
cat: 'Workshop',
status: 'Anmeldung offen',
img: '/images/networking-innenhof.jpg',
desc: 'Regionaler Austausch und Best-Practices für bayerische Unternehmen auf dem Weg zum Preis.',
slug: '/presse/events/mittelstands-gipfel-2026',
},
{
title: 'Nominierten-Auswahl',
date: '04. Aug 2026',
location: 'Regensburg',
cat: 'Jury-Sitzung',
status: 'Intern',
img: '/images/saal-gedeckt.jpg',
desc: 'Das Gremium sichtet die Ergebnisse der Audits und legt die Nominierten für die Hauptpreise fest.',
slug: '/presse/events/nominierten-auswahl-2026',
},
],
},
news: {
eyebrow: 'Newsroom',
heading: 'NACHRICHTEN & EINBLICKE.',
description: 'Berichte, Hintergründe und Einblicke rund um den bayerischen Mittelstand und den BMP.',
readMoreLabel: 'Weiterlesen',
missingTitleLabel: 'Presse',
missingCategoryLabel: 'Presse',
collectionFallbackImageFilename: 'gala-saal-overview.jpg',
fallbackItems: [
{
title: 'Wie Bayerns KMU die KI nutzen',
excerpt: 'Ein Deep-Dive in die Bewerbungsunterlagen 2024 zeigt: Der Mittelstand treibt die Digitalisierung aktiv voran.',
cat: 'Innovation',
img: '/images/gala-saal-overview.jpg',
slug: '/presse/blog/ki-nutzung-bayerischer-kmu',
},
{
title: 'Start der Ehrenamts-Initiative',
excerpt: 'Gemeinsam mit unseren Partnern fördern wir soziale Projekte mittelständischer Unternehmen.',
cat: 'Engagement',
img: '/images/networking-innenhof.jpg',
slug: '/presse/blog/ehrenamts-initiative-2026',
},
{
title: 'Bayerische Wirtschaft wächst',
excerpt: 'Neue Prognosen zeigen ein stabiles Wachstum für den Mittelstand – gute Aussichten für die Awards.',
cat: 'Wirtschaft',
img: '/images/gala-dinner.jpg',
slug: '/presse/blog/bayerische-wirtschaft-waechst',
},
],
},
downloads: {
eyebrow: 'Pressekontakt & Material',
heading: 'PRESSE-MATERIAL & KONTAKT.',
description: 'Laden Sie unser offizielles Material herunter oder kontaktieren Sie direkt unser Presseteam für individuelle Anfragen.',
kitLabel: 'Download Presse-Kit',
kitMeta: 'Print_BMP.zip – 1,5 MB',
kitUrl: '/Print_BMP.zip',
kitDownloadName: 'Print_BMP.zip',
contactEyebrow: 'Pressekontakt',
contactName: 'Tanja Meier',
contactLines: 'presse@bmp-bayern.de\n+49 89 123 456 99',
},
accreditation: {
eyebrow: 'Akkreditierung',
heading: 'AKKREDITIERUNG ANFRAGEN.',
description: 'Melden Sie sich für unsere Presse-Verteiler an oder fordern Sie eine Akkreditierung für die Gala-Verleihung an.',
mediumLabel: 'Medium / Redaktion *',
nameLabel: 'Ihr Name *',
emailLabel: 'E-Mail-Adresse *',
submitLabel: 'Akkreditierung anfragen',
successHeading: 'Anfrage eingegangen',
successMessage: 'Vielen Dank für Ihre Akkreditierungsanfrage. Unser Presseteam meldet sich zeitnah bei Ihnen.',
contactImageAlt: '',
},
}

View File

@@ -0,0 +1,59 @@
export const sponsorTierNumbers = [1, 2, 3] as const
export type SponsorTierNumber = (typeof sponsorTierNumbers)[number]
export type SponsorViewport = 'desktop' | 'tablet' | 'mobile' | 'narrow-mobile'
export type SponsorRecord = {
sortOrder?: number | null
tier: SponsorTierNumber
}
export function groupSponsorsByTier<T extends SponsorRecord>(partners: T[]): Record<SponsorTierNumber, T[]> {
const grouped: Record<SponsorTierNumber, Array<{ index: number; partner: T }>> = {
1: [],
2: [],
3: [],
}
partners.forEach((partner, index) => {
if (sponsorTierNumbers.includes(partner.tier)) grouped[partner.tier].push({ index, partner })
})
return {
1: grouped[1]
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
.map(({ partner }) => partner),
2: grouped[2]
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
.map(({ partner }) => partner),
3: grouped[3]
.sort((a, b) => Number(a.partner.sortOrder ?? a.index) - Number(b.partner.sortOrder ?? b.index))
.map(({ partner }) => partner),
}
}
export function sponsorColumns(tier: SponsorTierNumber, viewport: SponsorViewport): number {
if (viewport === 'desktop') return tier === 1 ? 2 : tier === 2 ? 3 : 4
if (viewport === 'tablet') return tier === 3 ? 3 : 2
if (viewport === 'narrow-mobile') return 1
return tier === 3 ? 2 : 1
}
export function sponsorViewport({
isMobile,
isNarrowMobile,
isTabletOrMobile,
}: {
isMobile: boolean
isNarrowMobile: boolean
isTabletOrMobile: boolean
}): SponsorViewport {
if (isNarrowMobile) return 'narrow-mobile'
if (isMobile) return 'mobile'
if (isTabletOrMobile) return 'tablet'
return 'desktop'
}
export function sponsorCardBasis(columns: number, gap: number): string {
return `calc((100% - ${(columns - 1) * gap}px) / ${columns})`
}

View File

@@ -0,0 +1,37 @@
export type PublicationQueryOptions = Readonly<{
draft: boolean
overrideAccess: boolean
}>
const publicPublicationQuery = Object.freeze({
draft: false,
overrideAccess: false,
}) satisfies PublicationQueryOptions
const previewPublicationQuery = Object.freeze({
draft: true,
overrideAccess: true,
}) satisfies PublicationQueryOptions
/**
* Keeps public Local API reads access-controlled while allowing the authenticated
* Next.js draft-mode route to intentionally load the latest draft versions.
*/
export function getPublicationQueryOptions(isPreview: boolean): PublicationQueryOptions {
return isPreview ? previewPublicationQuery : publicPublicationQuery
}
/**
* Adds an explicit status predicate for public indexes such as sitemaps while
* retaining the same anonymous access and draft behavior as public routes.
*/
export function getPublicPublishedQueryOptions() {
return {
...getPublicationQueryOptions(false),
where: {
_status: {
equals: 'published' as const,
},
},
}
}

View File

@@ -0,0 +1,58 @@
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]'])
/**
* Normalize an explicitly configured public URL to a bare origin. Bare deployment
* hostnames (such as Vercel's production URL) are treated as HTTPS.
*
* @param {string} value
* @param {{ production?: boolean }} [options]
*/
function normalizeSitemapBaseURL(
value,
{ production = process.env.NODE_ENV === 'production' } = {},
) {
const configuredURL = typeof value === 'string' ? value.trim() : ''
if (!configuredURL) {
throw new Error(
'Sitemap base URL is not configured. Set NEXT_PUBLIC_SERVER_URL or VERCEL_PROJECT_PRODUCTION_URL.',
)
}
const url = new URL(
/^[a-z][a-z\d+.-]*:\/\//i.test(configuredURL) ? configuredURL : `https://${configuredURL}`,
)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`Sitemap base URL must use HTTP or HTTPS, received ${url.protocol}`)
}
const isPlaceholderHostname =
url.hostname === 'example.com' || url.hostname.endsWith('.example.com')
if (
production &&
(url.protocol !== 'https:' || LOCAL_HOSTNAMES.has(url.hostname) || isPlaceholderHostname)
) {
throw new Error('Production sitemap base URL must be a public HTTPS origin.')
}
return url.origin
}
/**
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [environment]
*/
function getSitemapBaseURL(environment = process.env) {
const configuredURL =
environment.NEXT_PUBLIC_SERVER_URL || environment.VERCEL_PROJECT_PRODUCTION_URL
return normalizeSitemapBaseURL(configuredURL || '', {
production: environment.NODE_ENV === 'production',
})
}
module.exports = {
getSitemapBaseURL,
normalizeSitemapBaseURL,
}

View File

@@ -0,0 +1,65 @@
import { getSitemapBaseURL } from './sitemapBaseURL.cjs'
type SitemapDocument = {
slug?: string | null
spaPath?: string | null
updatedAt?: string | null
}
export type SitemapEntry = {
loc: string
lastmod: string
}
function normalizeStoredPath(value: unknown, expectedPrefix?: string): string | undefined {
if (typeof value !== 'string') return undefined
const path = value.trim()
if (!path.startsWith('/') || path.startsWith('//')) return undefined
const pathname = new URL(path, 'https://sitemap.invalid').pathname.replace(/\/+$/, '') || '/'
if (expectedPrefix && !pathname.startsWith(`${expectedPrefix}/`)) return undefined
return pathname
}
function normalizeSlug(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const slug = value.trim().replace(/^\/+|\/+$/g, '')
return slug || undefined
}
export function pageSitemapPath(page: SitemapDocument): string | undefined {
const storedPath = normalizeStoredPath(page.spaPath)
if (storedPath) return storedPath
const slug = normalizeSlug(page.slug)
if (!slug) return undefined
return slug === 'home' || slug === 'startseite' ? '/' : `/${slug}`
}
export function detailSitemapPath(
document: SitemapDocument,
prefix: '/presse/blog' | '/presse/events' | '/preistraeger',
): string | undefined {
const storedPath = normalizeStoredPath(document.spaPath, prefix)
if (storedPath) return storedPath
const slug = normalizeSlug(document.slug)
return slug ? `${prefix}/${slug}` : undefined
}
export function createSitemapEntry(
document: SitemapDocument,
path: string | undefined,
dateFallback: string,
): SitemapEntry | undefined {
if (!path) return undefined
return {
loc: `${getSitemapBaseURL()}${path}`,
lastmod: document.updatedAt || dateFallback,
}
}

View File

@@ -15,3 +15,86 @@ test.describe('Frontend', () => {
await expect(heading).toHaveText('Payload Website Template')
})
})
test.describe('Participation process timeline', () => {
for (const viewport of [
{ width: 1440, height: 900 },
{ width: 1024, height: 768 },
{ width: 768, height: 1024 },
{ width: 390, height: 844 },
]) {
test(`keeps all steps reachable at ${viewport.width}px`, async ({ page }) => {
await page.setViewportSize(viewport)
await page.goto('http://localhost:3000/teilnahme#schritte')
const timeline = page.locator('#schritte')
const eligibility = page.locator('#voraussetzungen')
const steps = timeline.locator('[data-testid^="timeline-step-"]')
await expect(timeline).toBeVisible()
if (viewport.width < 768) {
await expect(timeline.locator('h3')).toHaveCount(4)
} else {
await expect(steps).toHaveCount(4)
const nextButton = timeline.getByRole('button', { name: 'Nächster Schritt', exact: true })
const visibleTrackRatio = async (index: number) => steps.nth(index).evaluate((card) => {
const track = card.closest('[data-testid="timeline-track"]')
if (!track) return 0
const cardRect = card.getBoundingClientRect()
const trackRect = track.getBoundingClientRect()
const visibleWidth = Math.max(
0,
Math.min(cardRect.right, trackRect.right) - Math.max(cardRect.left, trackRect.left),
)
return visibleWidth / cardRect.width
})
for (let index = 0; index < 4; index += 1) {
await expect.poll(() => visibleTrackRatio(index)).toBeGreaterThan(0.55)
if (index < 3) {
await nextButton.click()
await expect(timeline.locator('[data-testid="timeline-progress-label"]')).toHaveText(
`${String(index + 2).padStart(2, '0')} / 04`,
)
}
}
const cardsOverlap = await steps.evaluateAll((cards) => cards.some((card, index) => {
const nextCard = cards[index + 1]
return nextCard ? card.getBoundingClientRect().right > nextCard.getBoundingClientRect().left : false
}))
expect(cardsOverlap).toBe(false)
expect(await timeline.evaluate((element) => element.getBoundingClientRect().height)).toBeLessThan(900)
}
const sectionGap = await page.evaluate(() => {
const timelineElement = document.querySelector('#schritte')
const eligibilityElement = document.querySelector('#voraussetzungen')
if (!timelineElement || !eligibilityElement) return Number.POSITIVE_INFINITY
return eligibilityElement.getBoundingClientRect().top - timelineElement.getBoundingClientRect().bottom
})
expect(Math.abs(sectionGap)).toBeLessThanOrEqual(2)
})
}
test('uses a fully readable non-animated reduced-motion interaction', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.setViewportSize({ width: 1024, height: 768 })
await page.goto('http://localhost:3000/teilnahme#schritte')
const timeline = page.locator('#schritte')
await timeline.getByRole('button', { name: 'Nächster Schritt', exact: true }).click()
await expect(timeline.locator('[data-testid="timeline-progress-label"]')).toHaveText('02 / 04')
await expect.poll(() => timeline.locator('[data-testid="timeline-step-2"]').evaluate((card) => {
const track = card.closest('[data-testid="timeline-track"]')
if (!track) return 0
const cardRect = card.getBoundingClientRect()
const trackRect = track.getBoundingClientRect()
return Math.max(0, Math.min(cardRect.right, trackRect.right) - Math.max(cardRect.left, trackRect.left)) / cardRect.width
})).toBeGreaterThan(0.55)
expect(await timeline.locator('[data-testid="timeline-track"]').evaluate((element) => (
getComputedStyle(element).scrollBehavior
))).toBe('auto')
})
})

View File

@@ -0,0 +1,170 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import About from '@/spa/pages/About'
const viewport = vi.hoisted(() => ({ mobile: false, stacked: false }))
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: (breakpoint = 768) => (breakpoint === 1024 ? viewport.stacked : viewport.mobile),
}))
vi.mock('next/navigation', () => ({
usePathname: () => '/der-bmp',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
vi.mock('@/spa/components/AwardsGridSection', () => ({
default: () => <section data-testid="awards-stub" />,
}))
vi.mock('@/spa/components/HomeWinnersSection', () => ({
default: () => <section data-testid="winners-stub" />,
}))
vi.mock('@/spa/components/TestimonialsSection', () => ({
default: () => <section data-testid="testimonials-stub" />,
}))
vi.mock('@/spa/components/ui/munich-skyline-bg', () => ({
default: () => null,
}))
const aboutRoute: CmsRouteData = {
collection: 'pages',
doc: {
id: 'about-page',
slug: 'der-bmp',
spaPath: '/der-bmp',
about: {
prize: {
image: {
url: '/api/media/file/cms-prize.jpg',
alt: 'CMS prize ceremony',
focalX: 32,
focalY: 68,
},
imageAlt: 'Page-owned prize fallback alt',
eyebrow: 'CMS prize eyebrow',
heading: 'CMS PRIZE\nHEADING',
body: [{ text: 'First CMS prize paragraph.' }, { text: 'Second CMS prize paragraph.' }],
facts: [
{ value: 'A', label: 'Founded' },
{
value: 'B',
label: 'An intentionally very long CMS-owned fact label that must remain readable',
},
{ value: 'C', label: 'Independent' },
],
imageLabel: 'CMS-owned prize image caption',
},
},
},
}
const renderAbout = () =>
render(
<CmsRouteProvider value={aboutRoute}>
<About />
</CmsRouteProvider>,
)
describe('/der-bmp prize facts and caption', () => {
beforeEach(() => {
viewport.mobile = false
viewport.stacked = false
})
afterEach(() => cleanup())
it('centers three equal fact cells and keeps full-height separators between them', () => {
renderAbout()
const facts = screen.getByTestId('prize-facts')
const cells = within(facts).getAllByTestId('prize-fact')
expect(facts.style.display).toBe('grid')
expect(facts.style.gridTemplateColumns).toBe('repeat(3, minmax(0, 1fr))')
expect(cells).toHaveLength(3)
expect(cells.every((cell) => cell.style.minWidth === '0')).toBe(true)
expect(cells.every((cell) => cell.style.alignItems === 'center')).toBe(true)
expect(cells.every((cell) => cell.style.textAlign === 'center')).toBe(true)
expect(cells.slice(0, 2).every((cell) => cell.style.borderRightWidth === '1px')).toBe(true)
expect(cells[2]?.style.borderRightStyle).toBe('none')
})
it('preserves long CMS fact labels without allowing them to escape their equal cells', () => {
renderAbout()
const labels = screen.getAllByTestId('prize-fact-label')
const longLabel = screen.getByText(
'An intentionally very long CMS-owned fact label that must remain readable',
)
expect(labels.map((label) => label.textContent)).toEqual([
'Founded',
'An intentionally very long CMS-owned fact label that must remain readable',
'Independent',
])
expect(labels.every((label) => label.style.maxWidth === '100%')).toBe(true)
expect(labels.every((label) => label.style.overflowWrap === 'anywhere')).toBe(true)
expect(longLabel.style.hyphens).toBe('auto')
})
it('keeps CMS media, alt text, focal point, and caption in a semantic contrast container', () => {
renderAbout()
const section = screen.getByTestId('prize-section')
const figure = within(section).getByTestId('prize-figure')
const image = within(section).getByRole('img', { name: 'CMS prize ceremony' })
const caption = within(section).getByTestId('prize-image-caption')
expect(figure.tagName).toBe('FIGURE')
expect(caption.tagName).toBe('FIGCAPTION')
expect(image.getAttribute('src')).toBe('/api/media/file/cms-prize.jpg')
expect(image.style.objectPosition).toBe('32% 68%')
expect(caption.textContent).toBe('CMS-owned prize image caption')
expect(caption.style.background).toBe('rgba(3, 9, 58, 0.92)')
expect(caption.style.color).toBe('rgb(255, 255, 255)')
expect(caption.style.borderLeftWidth).toBe('3px')
expect(image.compareDocumentPosition(caption) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('stacks the image first at tablet width while retaining equal fact structure', () => {
viewport.stacked = true
renderAbout()
const layout = screen.getByTestId('prize-layout')
const figure = screen.getByTestId('prize-figure')
const copy = screen.getByTestId('prize-copy-panel')
expect(layout.getAttribute('data-layout')).toBe('stacked')
expect(layout.style.gridTemplateColumns).toBe('1fr')
expect(figure.style.order).toBe('1')
expect(figure.style.minHeight).toBe('420px')
expect(copy.style.order).toBe('2')
expect(copy.style.padding).toBe('56px 48px')
expect(within(copy).getAllByTestId('prize-fact')).toHaveLength(3)
})
it('uses a dedicated mobile caption area without changing CMS copy or media order', () => {
viewport.mobile = true
viewport.stacked = true
renderAbout()
const figure = screen.getByTestId('prize-figure')
const visual = screen.getByTestId('prize-image-visual')
const caption = screen.getByTestId('prize-image-caption')
const copy = screen.getByTestId('prize-copy-panel')
expect(figure.style.display).toBe('flex')
expect(visual.style.minHeight).toBe('280px')
expect(caption.style.position).toBe('relative')
expect(caption.style.background).toBe('rgb(3, 9, 58)')
expect(caption.textContent).toBe('CMS-owned prize image caption')
expect(copy.compareDocumentPosition(figure) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.getByText('First CMS prize paragraph.')).toBeTruthy()
expect(screen.queryByText('BMP Preisverleihung · München')).toBeNull()
})
})

View File

@@ -0,0 +1,129 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import About from '@/spa/pages/About'
const viewport = vi.hoisted(() => ({ mobile: false, relevanceStacked: false }))
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: (breakpoint = 768) =>
breakpoint === 1024 ? viewport.relevanceStacked : viewport.mobile,
}))
vi.mock('next/navigation', () => ({
usePathname: () => '/der-bmp',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
vi.mock('@/spa/components/AwardsGridSection', () => ({
default: () => <section data-testid="awards-stub" />,
}))
vi.mock('@/spa/components/HomeWinnersSection', () => ({
default: () => <section data-testid="winners-stub" />,
}))
vi.mock('@/spa/components/TestimonialsSection', () => ({
default: () => <section data-testid="testimonials-stub" />,
}))
vi.mock('@/spa/components/ui/munich-skyline-bg', () => ({
default: () => null,
}))
const aboutRoute: CmsRouteData = {
collection: 'pages',
doc: {
id: 'about-page',
slug: 'der-bmp',
spaPath: '/der-bmp',
about: {
mittelstand: {
image: {
url: '/api/media/file/cms-relevance.jpg',
alt: 'CMS relevance event',
focalX: 38,
focalY: 42,
},
imageAlt: 'Page-owned fallback alt',
eyebrow: 'CMS relevance eyebrow',
heading: 'CMS RELEVANCE\nHEADING',
body: [
{ text: 'First CMS relevance paragraph.' },
{ text: 'Second CMS relevance paragraph.' },
],
},
},
},
}
const renderAbout = () =>
render(
<CmsRouteProvider value={aboutRoute}>
<About />
</CmsRouteProvider>,
)
describe('/der-bmp relevance section', () => {
beforeEach(() => {
viewport.mobile = false
viewport.relevanceStacked = false
})
afterEach(() => cleanup())
it('gives the image the larger desktop share and centers a readable copy measure', () => {
renderAbout()
const layout = screen.getByTestId('relevance-layout')
const copyPanel = screen.getByTestId('relevance-copy-panel')
const copy = screen.getByTestId('relevance-copy')
expect(layout.getAttribute('data-layout')).toBe('split')
expect(layout.style.gridTemplateColumns).toBe('55% 45%')
expect(copyPanel.style.padding).toBe('88px 64px')
expect(copy.style.width).toBe('100%')
expect(copy.style.maxWidth).toBe('520px')
expect(copy.style.margin).toBe('0px auto')
})
it('preserves CMS-owned copy and media while honoring the media focal point', () => {
renderAbout()
const section = screen.getByTestId('relevance-section')
const image = within(section).getByRole('img', { name: 'CMS relevance event' })
expect(image.getAttribute('src')).toBe('/api/media/file/cms-relevance.jpg')
expect(image.style.objectPosition).toBe('38% 42%')
expect(within(section).getByText('CMS relevance eyebrow')).toBeTruthy()
expect(within(section).getByRole('heading', { level: 2 }).textContent).toBe(
'CMS RELEVANCEHEADING',
)
expect(within(section).getByText('First CMS relevance paragraph.')).toBeTruthy()
expect(within(section).getByText('Second CMS relevance paragraph.')).toBeTruthy()
expect(
within(section).queryByText(
'Der Bayerische Mittelstandspreis ist weit mehr als eine Trophäe.',
),
).toBeNull()
})
it('keeps the image first with readable padding in the stacked mobile layout', () => {
viewport.mobile = true
viewport.relevanceStacked = true
renderAbout()
const layout = screen.getByTestId('relevance-layout')
const imagePanel = screen.getByTestId('relevance-image-panel')
const copyPanel = screen.getByTestId('relevance-copy-panel')
expect(layout.getAttribute('data-layout')).toBe('stacked')
expect(layout.style.gridTemplateColumns).toBe('1fr')
expect(imagePanel.style.minHeight).toBe('280px')
expect(copyPanel.style.padding).toBe('32px 24px 40px')
expect(
imagePanel.compareDocumentPosition(copyPanel) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
})

View File

@@ -0,0 +1,80 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApplicationFormSection } from '@/spa/components/forms/ApplicationFormSection'
const viewport = vi.hoisted(() => ({ mobile: false }))
const cms = vi.hoisted(() => ({ activePhase: '0' }))
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: () => viewport.mobile,
}))
vi.mock('@/spa/cmsRoute', () => ({
useApplicationForm: () => undefined,
useApplicationPhase: () => ({ activePhase: cms.activePhase }),
useNewsletterForm: () => undefined,
}))
vi.mock('@/spa/components/forms/BewerbungsForm', () => ({
default: () => <div>Application form</div>,
}))
vi.mock('@/spa/components/forms/NewsletterInterestForm', () => ({
NewsletterInterestForm: () => <div>Newsletter form</div>,
}))
vi.mock('@/spa/components/ui/UnoptimizedImage', () => ({
default: ({ alt }: { alt: string }) => <div role="img" aria-label={alt} />,
}))
describe('shared application form layout', () => {
beforeEach(() => {
viewport.mobile = false
cms.activePhase = '0'
})
afterEach(() => cleanup())
it('widens only the homepage blue panel to a five-to-seven desktop split', () => {
render(<ApplicationFormSection source="homepage" />)
const layout = screen.getByTestId('application-form-layout')
expect(layout.getAttribute('data-source')).toBe('homepage')
expect(layout.style.gridTemplateColumns).toBe('5fr 1px 7fr')
})
it('keeps the Teilnahme desktop split unchanged', () => {
render(<ApplicationFormSection source="participation" />)
const layout = screen.getByTestId('application-form-layout')
expect(layout.getAttribute('data-source')).toBe('participation')
expect(layout.style.gridTemplateColumns).toBe('4fr 1px 8fr')
})
it('preserves the shared mobile stack for both sources', () => {
viewport.mobile = true
const { rerender } = render(<ApplicationFormSection source="homepage" />)
expect(screen.getByTestId('application-form-layout').style.gridTemplateColumns).toBe('1fr')
rerender(<ApplicationFormSection source="participation" />)
expect(screen.getByTestId('application-form-layout').style.gridTemplateColumns).toBe('1fr')
})
it('keeps the homepage ratio across application and newsletter phases', () => {
const { rerender } = render(<ApplicationFormSection source="homepage" />)
expect(screen.getByText('Application form')).toBeTruthy()
expect(screen.getByTestId('application-form-layout').style.gridTemplateColumns).toBe(
'5fr 1px 7fr',
)
cms.activePhase = '1'
rerender(<ApplicationFormSection source="homepage" />)
expect(screen.getByText('Newsletter form')).toBeTruthy()
expect(screen.getByTestId('application-form-layout').style.gridTemplateColumns).toBe(
'5fr 1px 7fr',
)
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { resolveCmsRelationship, type CmsRouteDoc } from '@/spa/cmsRoute'
import { resolveCmsRelationship, resolveCmsRelationships, type CmsRouteDoc } from '@/spa/cmsRoute'
const fullWinner: CmsRouteDoc = {
id: 20,
@@ -17,9 +17,24 @@ describe('CMS relationship resolution', () => {
expect(resolveCmsRelationship(20, [fullWinner])).toBe(fullWinner)
})
it('preserves a populated relationship when no list document is available', () => {
it('rejects an embedded relationship when it is absent from the hydrated collection', () => {
const relationship = { id: 20, image: { id: 55, url: '/api/media/file/image%20(1).png' } }
expect(resolveCmsRelationship(relationship, [])).toBe(relationship)
expect(resolveCmsRelationship(relationship, [])).toBeUndefined()
})
it('preserves relationship selection order while omitting unavailable documents', () => {
const first = { id: 1, title: 'First' }
const second = { id: 2, title: 'Second' }
const third = { id: 3, title: 'Third' }
expect(resolveCmsRelationships([third, { id: 2, stale: true }, first], [first, third])).toEqual(
[third, first],
)
expect(resolveCmsRelationships([third, second, first], [first, second, third])).toEqual([
third,
second,
first,
])
})
})

View File

@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import { StatusSlider } from '@/spa/pages/Home'
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: () => false,
}))
vi.mock('next/navigation', () => ({
usePathname: () => '/',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
const applicationPhase = (headline: string): SpaApplicationPhaseData => ({
activePhase: '0',
noActionLabel: 'No action',
successApplicationsLabel: 'Applications',
successWinnersLabel: 'Winners',
phases: [
{
num: '03',
tag: 'Complete',
phase: 'Award ceremony',
headline,
body: 'CMS body copy',
accent: '#EFBF04',
bg: '#111D55',
meta: 'CMS meta copy',
successApplications: '247',
successWinners: '3',
},
],
})
describe('homepage application-phase headline', () => {
afterEach(() => cleanup())
it('renders each explicit CMS newline as an intentional line break', () => {
render(<StatusSlider data={applicationPhase('Ein weiteres\nerfolgreiches Jahr')} />)
const headline = screen.getByTestId('phase-headline')
expect(headline.textContent).toBe('Ein weitereserfolgreiches Jahr')
expect(headline.querySelectorAll('br')).toHaveLength(1)
expect(headline.childNodes[0]?.textContent).toBe('Ein weiteres')
expect(headline.childNodes[2]?.textContent).toBe('erfolgreiches Jahr')
})
it('keeps arbitrary CMS-edited copy authoritative and naturally wrappable', () => {
const editedHeadline =
'Eine redaktionell geänderte Überschrift mit zusätzlichem Inhalt für schmale Ansichten'
render(<StatusSlider data={applicationPhase(editedHeadline)} />)
const headline = screen.getByTestId('phase-headline')
expect(headline.textContent).toBe(editedHeadline)
expect(headline.querySelector('br')).toBeNull()
expect(headline.classList.contains('type-page-title')).toBe(true)
expect(screen.getByTestId('phase-section').style.height).toBe('auto')
expect(screen.getByTestId('phase-section').style.minHeight).toBe('480px')
})
it('keeps the requested copy out of the homepage component', () => {
const source = readFileSync(join(process.cwd(), 'src/spa/pages/Home.tsx'), 'utf8')
expect(source).not.toContain('Ein weiteres')
expect(source).not.toContain('erfolgreiches Jahr')
})
})

View File

@@ -18,15 +18,35 @@ const published2025Winners: CmsRouteDoc[] = Array.from({ length: 9 }, (_, index)
describe('home winner selection', () => {
it('uses a non-empty CMS selection without appending winners from the fallback year', () => {
const featured = selected2026Winners.map(({ id }) => id)
const winners = resolveHomeWinners(featured, [...selected2026Winners, ...published2025Winners], [])
const winners = resolveHomeWinners(featured, [...selected2026Winners, ...published2025Winners])
expect(winners).toEqual(selected2026Winners)
expect(winners.some((winner) => winner.year === 2025)).toBe(false)
})
it('falls back to at most eight 2025 winners when the CMS selection is empty', () => {
const winners = resolveHomeWinners([], published2025Winners, [])
const winners = resolveHomeWinners([], published2025Winners)
expect(winners).toEqual(published2025Winners.slice(0, 8))
})
it('omits an unpublished selected winner without changing the remaining page order', () => {
const [first, unpublished, third] = selected2026Winners
const selection = [third, { ...unpublished, image: { id: 99 } }, first]
const publicWinners = resolveHomeWinners(selection, [first, third])
const previewWinners = resolveHomeWinners(selection, [first, unpublished, third])
expect(publicWinners).toEqual([third, first])
expect(previewWinners).toEqual([third, unpublished, first])
})
it('does not replace an entirely unpublished CMS selection with fallback winners', () => {
const [unpublished] = selected2026Winners
expect(resolveHomeWinners([{ ...unpublished, image: { id: 99 } }], [])).toEqual([])
})
it('does not resurrect static winners when the published collection is empty', () => {
expect(resolveHomeWinners([], [])).toEqual([])
})
})

View File

@@ -13,6 +13,15 @@ describe('Netzwerk jury members', () => {
expect(selectJuryDocs([3, { id: 1 }], docs).map((member) => member.id)).toEqual([3, 1])
expect(selectJuryDocs([], docs)).toEqual([])
expect(selectJuryDocs(undefined, docs)).toEqual(docs)
expect(selectJuryDocs(undefined, [])).toEqual([])
})
it('omits an unpublished selected member publicly and restores it in preview order', () => {
const selection = [{ id: 3 }, { id: 2, name: 'Stale embedded member' }, { id: 1 }]
const publicDocs = [docs[0], docs[2]]
expect(selectJuryDocs(selection, publicDocs).map((member) => member.id)).toEqual([3, 1])
expect(selectJuryDocs(selection, docs).map((member) => member.id)).toEqual([3, 2, 1])
})
it('places non-empty quotes first and preserves sort order within each group', () => {

View File

@@ -0,0 +1,203 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import { netzwerkContent } from '@/spa/netzwerkContent'
import Netzwerk from '@/spa/pages/Netzwerk'
import {
groupSponsorsByTier,
sponsorCardBasis,
sponsorColumns,
sponsorViewport,
} from '@/spa/sponsorHierarchy'
vi.mock('next/navigation', () => ({
usePathname: () => '/netzwerk',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
let viewportWidth = 1440
const partner = (name: string, tier: 1 | 2 | 3, sortOrder: number) => ({
id: name.toLowerCase().replace(/\s/g, '-'),
stableId: name.toLowerCase().replace(/\s/g, '-'),
name,
tier,
sortOrder,
active: true,
desc: `${name} description`,
fullDesc: `${name} details`,
industry: `${name} industry`,
location: `${name} location`,
website: `${name.toLowerCase()}.example`,
})
const partners = [
partner('Tier One B', 1, 200),
partner('Tier Three H', 3, 8),
partner('Tier Two B', 2, 20),
partner('Tier Three A', 3, 1),
partner('Tier One A', 1, 100),
partner('Tier Three C', 3, 3),
partner('Tier Two A', 2, 10),
partner('Tier Three B', 3, 2),
partner('Tier Three D', 3, 4),
partner('Tier Three E', 3, 5),
partner('Tier Three F', 3, 6),
partner('Tier Three G', 3, 7),
]
const sponsorCopy = {
eyebrow: 'CMS sponsor eyebrow',
heading: 'CMS sponsor heading',
description: 'CMS sponsor supporting copy',
detailLabel: 'CMS sponsor action',
tiers: [
{ tier: 1, label: 'CMS feature tier', note: 'CMS feature tier note' },
{ tier: 2, label: 'CMS standard tier', note: 'CMS standard tier note' },
{ tier: 3, label: 'CMS compact tier', note: 'CMS compact tier note' },
],
}
const routeData = (): CmsRouteData => ({
collection: 'pages',
doc: {
id: 'network-page',
slug: 'netzwerk',
spaPath: '/netzwerk',
netzwerk: { partners: sponsorCopy },
},
lists: {
partners,
'jury-mitglieder': [],
},
})
function renderNetwork(width: number) {
viewportWidth = width
return render(
<CmsRouteProvider value={routeData()}>
<Netzwerk />
</CmsRouteProvider>,
)
}
describe('responsive sponsor hierarchy', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => {
const maximum = Number(query.match(/max-width:\s*(\d+)px/)?.[1] || Number.POSITIVE_INFINITY)
return {
addEventListener: vi.fn(),
matches: viewportWidth <= maximum,
media: query,
removeEventListener: vi.fn(),
}
}),
})
})
afterEach(() => cleanup())
it('keeps tiers separate and applies Partner sortOrder only within each tier', () => {
const grouped = groupSponsorsByTier(partners)
expect(grouped[1].map(({ name }) => name)).toEqual(['Tier One A', 'Tier One B'])
expect(grouped[2].map(({ name }) => name)).toEqual(['Tier Two A', 'Tier Two B'])
expect(grouped[3].map(({ name }) => name)).toEqual([
'Tier Three A',
'Tier Three B',
'Tier Three C',
'Tier Three D',
'Tier Three E',
'Tier Three F',
'Tier Three G',
'Tier Three H',
])
expect([...grouped[1], ...grouped[2], ...grouped[3]].map(({ name }) => name)).toEqual([
'Tier One A',
'Tier One B',
'Tier Two A',
'Tier Two B',
'Tier Three A',
'Tier Three B',
'Tier Three C',
'Tier Three D',
'Tier Three E',
'Tier Three F',
'Tier Three G',
'Tier Three H',
])
})
it('defines the approved columns and conservative narrow-mobile fallback', () => {
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'desktop'))).toEqual([2, 3, 4])
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'tablet'))).toEqual([2, 2, 3])
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'mobile'))).toEqual([1, 1, 2])
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'narrow-mobile'))).toEqual([
1, 1, 1,
])
expect(
sponsorViewport({ isMobile: false, isNarrowMobile: false, isTabletOrMobile: true }),
).toBe('tablet')
expect(sponsorCardBasis(3, 16)).toBe('calc((100% - 32px) / 3)')
})
it('renders every visible sponsor-section label from Netzwerk CMS fields', () => {
renderNetwork(1440)
expect(screen.getByText(sponsorCopy.eyebrow)).toBeTruthy()
expect(screen.getByText(sponsorCopy.heading)).toBeTruthy()
expect(screen.getByText(sponsorCopy.description)).toBeTruthy()
sponsorCopy.tiers.forEach(({ label, note }) => {
expect(screen.getByRole('heading', { name: label })).toBeTruthy()
expect(screen.getByText(note)).toBeTruthy()
})
expect(screen.getAllByText(sponsorCopy.detailLabel)).toHaveLength(partners.length)
expect(screen.queryByText(netzwerkContent.partners.description)).toBeNull()
netzwerkContent.partners.tiers.forEach(({ label }) =>
expect(screen.queryByText(label)).toBeNull(),
)
})
it.each([
{ width: 1440, viewport: 'desktop', columns: [2, 3, 4] },
{ width: 1024, viewport: 'tablet', columns: [2, 2, 3] },
{ width: 390, viewport: 'mobile', columns: [1, 1, 2] },
{ width: 340, viewport: 'narrow-mobile', columns: [1, 1, 1] },
])('centers independent bands at $width px', ({ width, viewport, columns }) => {
renderNetwork(width)
columns.forEach((columnCount, index) => {
const tier = index + 1
const band = screen.getByTestId(`sponsor-tier-${tier}`)
const cards = screen.getByTestId(`sponsor-tier-${tier}-cards`)
expect(band.getAttribute('data-columns')).toBe(String(columnCount))
expect(band.getAttribute('data-viewport')).toBe(viewport)
expect(cards.style.display).toBe('flex')
expect(cards.style.flexWrap).toBe('wrap')
expect(cards.style.justifyContent).toBe('center')
expect(within(band).getAllByRole('button')).toHaveLength(tier === 3 ? 8 : 2)
})
})
it('keeps sponsor defaults and legacy labels out of the rendered component source', () => {
const source = readFileSync(join(process.cwd(), 'src/spa/pages/Netzwerk.tsx'), 'utf8')
const visibleDefaults = [
netzwerkContent.partners.eyebrow,
netzwerkContent.partners.heading,
netzwerkContent.partners.description,
netzwerkContent.partners.detailLabel,
...netzwerkContent.partners.tiers.map(({ label }) => label),
'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.',
]
visibleDefaults.forEach((copy) => {
expect(source).not.toContain(`'${copy}'`)
expect(source).not.toContain(`"${copy}"`)
})
})
})

View File

@@ -0,0 +1,120 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { CmsRouteProvider, type CmsRouteData, type CmsRouteDoc } from '@/spa/cmsRoute'
import { netzwerkContent } from '@/spa/netzwerkContent'
import Netzwerk from '@/spa/pages/Netzwerk'
vi.mock('next/navigation', () => ({
usePathname: () => '/netzwerk',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
const homePage = (videoUrl: string): CmsRouteDoc => ({
id: `home-${videoUrl}`,
slug: 'startseite',
spaPath: '/',
home: {
hero: { videoLabel: 'Homepage film' },
videoModal: {
video: { url: videoUrl },
title: 'Homepage video',
closeLabel: 'Homepage video close',
},
},
})
const networkRoute = (video?: { url: string; label: string; title: string }): CmsRouteData => ({
collection: 'pages',
doc: {
id: 'network-page',
slug: 'netzwerk',
spaPath: '/netzwerk',
netzwerk: {
patronage: {
...(video
? {
greetingVideo: { url: video.url },
greetingVideoButtonLabel: video.label,
greetingVideoTitle: video.title,
greetingVideoDescription: `${video.title} description`,
greetingVideoCloseLabel: `${video.title} close`,
}
: {}),
},
},
},
lists: {
pages: [homePage('/api/media/file/homepage.mp4')],
partners: [],
'jury-mitglieder': [],
},
})
describe('Netzwerk greeting video', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
addEventListener: vi.fn(),
matches: false,
media: query,
removeEventListener: vi.fn(),
})),
})
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
})
afterEach(() => cleanup())
it('does not fall back to the homepage video when Netzwerk media is empty', () => {
render(
<CmsRouteProvider value={networkRoute()}>
<Netzwerk />
</CmsRouteProvider>,
)
expect(screen.queryByRole('button', { name: netzwerkContent.patronage.greetingVideoButtonLabel })).toBeNull()
expect(screen.queryByLabelText('Homepage video')).toBeNull()
})
it('resolves the page-owned Netzwerk media independently of homepage changes', () => {
const firstNetworkVideo = {
url: '/api/media/file/network-first.mp4',
label: 'Play Netzwerk greeting',
title: 'Netzwerk greeting one',
}
const secondNetworkVideo = {
url: '/api/media/file/network-second.mp4',
label: 'Play second Netzwerk greeting',
title: 'Netzwerk greeting two',
}
const { rerender } = render(
<CmsRouteProvider value={networkRoute(firstNetworkVideo)}>
<Netzwerk />
</CmsRouteProvider>,
)
fireEvent.click(screen.getByRole('button', { name: firstNetworkVideo.label }))
expect(screen.getByRole('dialog').querySelector('video')?.getAttribute('src')).toBe(firstNetworkVideo.url)
const homeChanged = networkRoute(firstNetworkVideo)
homeChanged.lists = {
...homeChanged.lists,
pages: [homePage('/api/media/file/homepage-changed.mp4')],
}
rerender(
<CmsRouteProvider value={homeChanged}>
<Netzwerk />
</CmsRouteProvider>,
)
expect(screen.getByRole('dialog').querySelector('video')?.getAttribute('src')).toBe(firstNetworkVideo.url)
rerender(
<CmsRouteProvider value={networkRoute(secondNetworkVideo)}>
<Netzwerk />
</CmsRouteProvider>,
)
expect(screen.getByRole('dialog').querySelector('video')?.getAttribute('src')).toBe(secondNetworkVideo.url)
})
})

View File

@@ -0,0 +1,157 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import Participation from '@/spa/pages/Participation'
const viewport = vi.hoisted(() => ({ mobile: false }))
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: () => viewport.mobile,
}))
vi.mock('next/navigation', () => ({
usePathname: () => '/teilnahme',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
vi.mock('@/spa/components/WegZurAuszeichnungSection', () => ({
default: () => <section data-testid="timeline-stub" />,
}))
vi.mock('@/spa/components/AwardsGridSection', () => ({
default: () => <section data-testid="awards-stub" />,
}))
vi.mock('@/spa/components/forms/ApplicationFormSection', () => ({
ApplicationFormSection: () => <section data-testid="application-form-stub" />,
}))
vi.mock('@/spa/components/ui/UnoptimizedImage', () => ({
default: ({ alt }: { alt: string }) => <div role="img" aria-label={alt} />,
}))
const participationRoute: CmsRouteData = {
collection: 'pages',
doc: {
id: 'participation-page',
slug: 'teilnahme',
spaPath: '/teilnahme',
participation: {
eligibility: {
eyebrow: 'CMS eligibility eyebrow',
heading: 'CMS ELIGIBILITY\nHEADING',
description: 'CMS eligibility introduction.',
primaryCta: { label: 'CMS eligibility CTA', url: '/kontakt' },
criteria: [
{
num: 'A',
title: 'First CMS criterion',
body: 'First CMS criterion body.',
icon: 'mapPin',
},
{
num: 'B',
title: 'Second CMS criterion',
body: 'Second CMS criterion body.',
icon: 'users',
},
],
notes: [
{ title: 'First CMS note', body: 'First CMS note body.', icon: 'building2' },
{ title: 'Second CMS note', body: 'Second CMS note body.', icon: 'userCheck' },
],
nudgeText: 'CMS eligibility conclusion.',
},
datesBanner: {
heading: 'CMS important dates',
description: 'CMS dates description.',
mobileItems: [{ date: 'Mobile date', label: 'Mobile label' }],
desktopItems: [{ date: 'Desktop date', label: 'Desktop label' }],
},
},
},
}
const renderParticipation = () =>
render(
<CmsRouteProvider value={participationRoute}>
<Participation />
</CmsRouteProvider>,
)
describe('participation eligibility hierarchy', () => {
beforeEach(() => {
viewport.mobile = false
})
afterEach(() => cleanup())
it('keeps CMS eligibility content and its CTA authoritative inside semantic groups', () => {
renderParticipation()
const section = screen.getByTestId('eligibility-section')
const criteria = within(section).getAllByTestId('eligibility-criterion')
const notes = within(section).getAllByTestId('eligibility-note')
const cta = within(section).getByRole('link', { name: 'CMS eligibility CTA' })
expect(within(section).getByRole('heading', { level: 2 }).textContent).toBe(
'CMS ELIGIBILITYHEADING',
)
expect(within(section).getByText('CMS eligibility introduction.')).toBeTruthy()
expect(criteria.map((row) => row.textContent)).toEqual([
'AFirst CMS criterionFirst CMS criterion body.',
'BSecond CMS criterionSecond CMS criterion body.',
])
expect(criteria.every((row) => row.tagName === 'LI')).toBe(true)
expect(criteria[0]?.parentElement?.tagName).toBe('OL')
expect(notes.map((note) => note.textContent)).toEqual([
'First CMS noteFirst CMS note body.',
'Second CMS noteSecond CMS note body.',
])
expect(within(section).getByText('CMS eligibility conclusion.')).toBeTruthy()
expect(cta.getAttribute('href')).toBe('/kontakt')
expect(within(section).getAllByRole('link')).toHaveLength(1)
})
it('provides restrained group and dates-transition hooks on desktop', () => {
renderParticipation()
expect(screen.getByTestId('eligibility-header').style.background).toBe('rgb(255, 255, 255)')
expect(screen.getByTestId('eligibility-criteria-group').style.background).toBe(
'rgb(244, 246, 248)',
)
expect(screen.getByTestId('eligibility-summary').style.background).toBe('rgb(17, 29, 85)')
expect(screen.getByTestId('important-dates-transition').style.borderTopWidth).toBe('8px')
expect(screen.getByRole('heading', { name: 'CMS important dates', level: 3 })).toBeTruthy()
expect(screen.getByText('Desktop date')).toBeTruthy()
expect(screen.queryByText('Mobile date')).toBeNull()
})
it('keeps criteria, notes, and conclusion in a single readable mobile order', () => {
viewport.mobile = true
renderParticipation()
const section = screen.getByTestId('eligibility-section')
const criteriaGroup = within(section).getByTestId('eligibility-criteria-group')
const notesGroup = within(section).getByTestId('eligibility-notes-group')
const summary = within(section).getByTestId('eligibility-summary')
const rows = within(criteriaGroup).getAllByTestId('eligibility-criterion')
expect(rows.map((row) => row.getAttribute('data-order'))).toEqual(['1', '2'])
expect(rows.every((row) => row.style.gridTemplateColumns === '40px 1fr')).toBe(true)
expect(
criteriaGroup.compareDocumentPosition(notesGroup) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
expect(
notesGroup.compareDocumentPosition(summary) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
expect(screen.getByTestId('important-dates-transition').style.borderTopWidth).toBe('5px')
expect(screen.getByTestId('important-date-item').getAttribute('data-order')).toBe('1')
expect(screen.getByTestId('important-date-item').style.gridTemplateColumns).toBe(
'minmax(112px, 0.9fr) minmax(0, 1.1fr)',
)
expect(screen.getByText('Mobile date')).toBeTruthy()
expect(screen.queryByText('Desktop date')).toBeNull()
})
})

View File

@@ -0,0 +1,91 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection'
import {
getActiveTimelineStep,
getTimelineScrollBehavior,
getTimelineStepScrollLeft,
} from '@/spa/components/ui/process-timeline'
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: () => false,
}))
let reduceMotion = false
describe('participation timeline', () => {
beforeEach(() => {
reduceMotion = false
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
addEventListener: vi.fn(),
matches: query === '(prefers-reduced-motion: reduce)' && reduceMotion,
media: query,
removeEventListener: vi.fn(),
})),
})
})
afterEach(() => cleanup())
it('maps the real horizontal scroll range to deterministic steps', () => {
expect(getActiveTimelineStep(0, 600, 4)).toBe(0)
expect(getActiveTimelineStep(200, 600, 4)).toBe(1)
expect(getActiveTimelineStep(400, 600, 4)).toBe(2)
expect(getActiveTimelineStep(600, 600, 4)).toBe(3)
expect(getTimelineStepScrollLeft(2, 600, 4)).toBe(400)
expect(getTimelineStepScrollLeft(99, 600, 4)).toBe(600)
})
it('updates progress from native track scroll state', () => {
render(<WegZurAuszeichnungSection />)
const track = screen.getByTestId('timeline-track')
Object.defineProperties(track, {
clientWidth: { configurable: true, value: 400 },
scrollLeft: { configurable: true, value: 400, writable: true },
scrollWidth: { configurable: true, value: 1000 },
})
fireEvent.scroll(track)
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('03 / 04')
expect(screen.getByTestId('timeline-progress').style.width).toBe('75%')
expect(screen.getAllByTestId(/timeline-step-/)).toHaveLength(4)
})
it('uses immediate scrolling in reduced-motion mode while keeping controls operable', () => {
reduceMotion = true
render(<WegZurAuszeichnungSection />)
const track = screen.getByTestId('timeline-track')
const scrollTo = vi.fn()
Object.defineProperties(track, {
clientWidth: { configurable: true, value: 400 },
scrollTo: { configurable: true, value: scrollTo },
scrollWidth: { configurable: true, value: 1000 },
})
fireEvent.click(screen.getByRole('button', { name: 'Nächster Schritt' }))
expect(getTimelineScrollBehavior(true)).toBe('auto')
expect(scrollTo).toHaveBeenCalledWith({ behavior: 'auto', left: 200 })
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('02 / 04')
})
it('supports direct keyboard navigation across the track', () => {
render(<WegZurAuszeichnungSection />)
const track = screen.getByTestId('timeline-track')
const scrollTo = vi.fn()
Object.defineProperties(track, {
clientWidth: { configurable: true, value: 400 },
scrollTo: { configurable: true, value: scrollTo },
scrollWidth: { configurable: true, value: 1000 },
})
fireEvent.keyDown(track, { key: 'End' })
expect(scrollTo).toHaveBeenCalledWith({ behavior: 'smooth', left: 600 })
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('04 / 04')
})
})

View File

@@ -0,0 +1,73 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import {
PARTNER_TICKER_ITEM_WIDTH_PX,
PARTNER_TICKER_MIN_DURATION_SECONDS,
PARTNER_TICKER_SPEED_PX_PER_SECOND,
PartnerTicker,
partnerTickerDurationSeconds,
} from '@/spa/components/ui/partner-ticker'
const logos = ['RSM', 'WWK', 'münchen.tv'].map((name) => ({
id: name,
label: <span>{name}</span>,
}))
describe('homepage partner ticker', () => {
afterEach(() => cleanup())
it('derives a calm duration from the exact single-copy track length', () => {
expect(partnerTickerDurationSeconds(0)).toBe(0)
expect(partnerTickerDurationSeconds(1)).toBe(PARTNER_TICKER_MIN_DURATION_SECONDS)
expect(partnerTickerDurationSeconds(12)).toBe(
(12 * PARTNER_TICKER_ITEM_WIDTH_PX) / PARTNER_TICKER_SPEED_PX_PER_SECOND,
)
expect(partnerTickerDurationSeconds(12)).toBe(65)
})
it('duplicates one identical track and translates by exactly one copy', () => {
render(<PartnerTicker ariaLabel="Unsere Partner" logos={logos} />)
const copies = screen.getAllByTestId('partner-ticker-copy')
expect(copies).toHaveLength(2)
expect(copies[1].getAttribute('aria-hidden')).toBe('true')
expect(within(copies[0]).getAllByText(/RSM|WWK|münchen.tv/)).toHaveLength(logos.length)
expect(within(copies[1]).getAllByText(/RSM|WWK|münchen.tv/)).toHaveLength(logos.length)
const ticker = screen.getByTestId('partner-ticker')
const styles = ticker.querySelector('style')?.textContent || ''
expect(styles).toContain('to { transform: translateX(-50%); }')
})
it('pauses for pointer hover and keyboard focus within the ticker', () => {
render(<PartnerTicker ariaLabel="Unsere Partner" logos={logos} />)
const ticker = screen.getByTestId('partner-ticker')
const track = screen.getByTestId('partner-ticker-track')
expect(track.style.animationPlayState).toBe('running')
fireEvent.mouseEnter(ticker)
expect(track.style.animationPlayState).toBe('paused')
fireEvent.mouseLeave(ticker)
expect(track.style.animationPlayState).toBe('running')
fireEvent.focus(ticker)
expect(track.style.animationPlayState).toBe('paused')
fireEvent.blur(ticker)
expect(track.style.animationPlayState).toBe('running')
})
it('provides a non-animated, horizontally accessible reduced-motion presentation', () => {
render(<PartnerTicker ariaLabel="Unsere Partner" logos={logos} />)
const ticker = screen.getByTestId('partner-ticker')
const styles = ticker.querySelector('style')?.textContent || ''
expect(ticker.getAttribute('tabindex')).toBe('0')
expect(styles).toContain('@media (prefers-reduced-motion: reduce)')
expect(styles).toContain('overflow-x: auto !important')
expect(styles).toContain('animation: none !important')
expect(styles).toContain('.partner-ticker-copy[aria-hidden="true"]')
expect(styles).toContain('display: none !important')
})
})

View File

@@ -0,0 +1,48 @@
import type { Field, NumberField, NumberFieldSingleValidation } from 'payload'
import { describe, expect, it } from 'vitest'
import { Partners } from '@/collections/Partners'
function partnerField(name: string): Field {
const field = Partners.fields.find((candidate) => 'name' in candidate && candidate.name === name)
if (!field) throw new Error(`Missing Partners field: ${name}`)
return field
}
describe('Partners admin ordering contract', () => {
it('shows the title and ordering state needed for content operations', () => {
expect(Partners.admin?.useAsTitle).toBe('name')
expect(Partners.admin?.defaultColumns).toEqual([
'name',
'tier',
'sortOrder',
'_status',
'active',
'showOnHomepage',
'updatedAt',
])
})
it('accepts only numeric tiers 1, 2, and 3', async () => {
const tierField = partnerField('tier')
expect(tierField).toMatchObject({
type: 'number',
min: 1,
max: 3,
required: true,
admin: { step: 1 },
})
const validate = (tierField as NumberField).validate as NumberFieldSingleValidation | undefined
expect(validate).toBeTypeOf('function')
for (const value of [1, 2, 3]) {
expect(await validate?.(value, {} as never)).toBe(true)
}
for (const value of [0, 1.5, 4, Number.NaN, null, undefined]) {
expect(await validate?.(value, {} as never)).toBe('Tier must be one of 1, 2, or 3.')
}
})
})

View File

@@ -0,0 +1,185 @@
import type { Field } from 'payload'
import { describe, expect, it } from 'vitest'
import { ApplicationForm } from '@/ApplicationForm/config'
import { ApplicationPhase } from '@/ApplicationPhase/config'
import { Footer } from '@/Footer/config'
import { Header } from '@/Header/config'
import { NewsletterForm } from '@/NewsletterForm/config'
import { SiteSettings } from '@/SiteSettings/config'
import { aboutFields } from '@/collections/Pages/aboutFields'
import { homeFields } from '@/collections/Pages/homeFields'
import { netzwerkFields } from '@/collections/Pages/netzwerkFields'
import { participationFields } from '@/collections/Pages/participationFields'
import { pressIndexFields } from '@/collections/Pages/pressIndexFields'
function fieldPaths(fields: Field[], prefix = ''): string[] {
return fields.flatMap((field) => {
if (!('name' in field) || typeof field.name !== 'string') {
return 'fields' in field && Array.isArray(field.fields) ? fieldPaths(field.fields, prefix) : []
}
const path = prefix ? `${prefix}.${field.name}` : field.name
const children = 'fields' in field && Array.isArray(field.fields) ? fieldPaths(field.fields, path) : []
return [path, ...children]
})
}
function findField(fields: Field[], path: string): Field | undefined {
const [name, ...rest] = path.split('.')
const field = fields.find((candidate) => 'name' in candidate && candidate.name === name)
if (!field || rest.length === 0) return field
if (!('fields' in field) || !Array.isArray(field.fields)) return undefined
return findField(field.fields, rest.join('.'))
}
describe('Payload field inventory', () => {
it('keeps obsolete page controls out of the schema', () => {
const paths = [
...fieldPaths(homeFields),
...fieldPaths(participationFields),
...fieldPaths(aboutFields),
...fieldPaths(pressIndexFields),
...fieldPaths(netzwerkFields),
]
expect(paths).not.toEqual(
expect.arrayContaining([
'home.application',
'home.form',
'participation.applicationForm',
'participation.form',
'participation.eligibility.secondaryCta',
'participation.eligibility.nudgeCta',
'participation.applicationWays.recommendedLabel',
'participation.applicationWays.fallbackCta',
'participation.applicationWays.ways.featured',
'participation.applicationWays.ways.cta',
'about.highlights',
'pressIndex.events.detailLabel',
'pressIndex.news.readMoreLabel',
'pressIndex.downloads.kitLabel',
'pressIndex.downloads.kitMeta',
'pressIndex.downloads.kitFile',
'pressIndex.downloads.kitUrl',
'pressIndex.downloads.kitDownloadName',
'pressIndex.accreditation',
'netzwerk.jury.note',
'netzwerk.partners.premiumLabel',
'netzwerk.partners.defaultLogoColor',
'netzwerk.sponsoring.footnotePrefix',
'netzwerk.sponsoring.footnoteLabel',
'netzwerk.sponsoring.footnoteUrl',
'netzwerk.sponsoring.formEyebrow',
'netzwerk.sponsoringForm.packages',
'netzwerk.sponsoringForm.step1Eyebrow',
'netzwerk.sponsoringForm.step1Heading',
'netzwerk.sponsoringForm.requiredPackageError',
]),
)
})
it('retains rendered page controls and authoritative relationships', () => {
const paths = [
...fieldPaths(homeFields),
...fieldPaths(participationFields),
...fieldPaths(aboutFields),
...fieldPaths(pressIndexFields),
...fieldPaths(netzwerkFields),
]
expect(paths).toEqual(
expect.arrayContaining([
'home.hero',
'home.winners.featured',
'home.videoModal.video',
'participation.process.steps',
'participation.awardsGrid.cards',
'about.testimonials.items',
'pressIndex.events.statusLabels',
'pressIndex.downloads.contactName',
'pressIndex.downloads.contactImage',
'pressIndex.downloads.contactImageAlt',
'netzwerk.jury.members',
'netzwerk.patronage.greetingVideo',
'netzwerk.patronage.greetingVideoButtonLabel',
'netzwerk.patronage.greetingVideoTitle',
'netzwerk.patronage.greetingVideoDescription',
'netzwerk.patronage.greetingVideoCloseLabel',
'netzwerk.partners.eyebrow',
'netzwerk.partners.heading',
'netzwerk.partners.description',
'netzwerk.partners.detailLabel',
'netzwerk.partners.tiers',
'netzwerk.partners.tiers.label',
'netzwerk.partners.tiers.note',
'netzwerk.partners.modal.websiteLabel',
'netzwerk.sponsoringForm.steps',
'netzwerk.sponsoringForm.requiredFieldError',
]),
)
})
it('keeps shared multi-page and site-wide content in Globals', () => {
const globals = [ApplicationForm, ApplicationPhase, NewsletterForm, Header, Footer, SiteSettings]
const slugs = globals.map((global) => global.slug)
expect(slugs).toEqual([
'application-form',
'application-phase',
'newsletter-form',
'header',
'footer',
'site-settings',
])
expect(fieldPaths(ApplicationForm.fields)).toEqual(expect.arrayContaining(['section', 'form']))
expect(fieldPaths(ApplicationPhase.fields)).toEqual(expect.arrayContaining(['activePhase', 'phases']))
expect(fieldPaths(NewsletterForm.fields)).toEqual(expect.arrayContaining(['evaluation', 'completed']))
})
it('exposes exactly two editable sponsoring step labels', () => {
expect(findField(netzwerkFields, 'netzwerk.sponsoringForm.steps')).toMatchObject({
minRows: 2,
maxRows: 2,
})
})
it('limits the Netzwerk greeting upload to video media', () => {
const field = findField(netzwerkFields, 'netzwerk.patronage.greetingVideo')
expect(field).toMatchObject({
type: 'upload',
relationTo: 'media',
filterOptions: {
mimeType: {
contains: 'video',
},
},
})
expect(field).not.toHaveProperty('defaultValue')
})
it('keeps sponsor tier copy separate from collection-owned tier assignment and ordering', () => {
expect(findField(netzwerkFields, 'netzwerk.partners.tiers')).toMatchObject({
type: 'array',
dbName: 'network_tiers',
})
expect(findField(netzwerkFields, 'netzwerk.partners.tiers.label')).toMatchObject({ type: 'text' })
expect(findField(netzwerkFields, 'netzwerk.partners.tiers.note')).toMatchObject({ type: 'text' })
})
it('limits the press contact upload to image media', () => {
const field = findField(pressIndexFields, 'pressIndex.downloads.contactImage')
expect(field).toMatchObject({
type: 'upload',
relationTo: 'media',
filterOptions: {
mimeType: {
contains: 'image',
},
},
})
expect(field).not.toHaveProperty('defaultValue')
})
})

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { pressEventsFromCms, pressNewsFromCms } from '@/spa/pressCollectionItems'
import { pressIndexContent } from '@/spa/pressIndexContent'
describe('press collection items', () => {
it('does not replace an empty published events collection with demo event rows', () => {
expect(
pressEventsFromCms([], '/images/event-placeholder.jpg', {}, pressIndexContent.events),
).toEqual([])
})
it('does not replace an empty published posts collection with demo news cards', () => {
expect(pressNewsFromCms([], '/images/news-placeholder.jpg', pressIndexContent.news)).toEqual([])
})
})

View File

@@ -0,0 +1,123 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import { pressIndexContent } from '@/spa/pressIndexContent'
import Press from '@/spa/pages/Press'
const viewport = vi.hoisted(() => ({ mobile: false }))
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: () => viewport.mobile,
}))
vi.mock('next/navigation', () => ({
usePathname: () => '/presse',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
const pressRoute = (contactImage?: unknown, contactImageAlt?: string): CmsRouteData => ({
collection: 'pages',
doc: {
id: 'press-page',
slug: 'presse',
spaPath: '/presse',
pressIndex: {
downloads: {
contactImage,
contactImageAlt,
},
},
},
lists: {
events: [],
posts: [],
},
})
describe('Presse contact image', () => {
beforeEach(() => {
viewport.mobile = false
})
afterEach(() => cleanup())
it('renders selected media and page-owned alt text in the desktop right column', () => {
render(
<CmsRouteProvider
value={pressRoute(
{ url: '/api/media/file/press-contact.jpg', alt: 'Media default alt' },
'Press team portrait',
)}
>
<Press />
</CmsRouteProvider>,
)
const layout = screen.getByTestId('press-contact-layout')
const imageColumn = screen.getByTestId('press-contact-image')
const image = within(imageColumn).getByRole('img', { name: 'Press team portrait' })
expect(layout.getAttribute('data-layout')).toBe('two-column')
expect(layout.style.gridTemplateColumns).toBe('minmax(0, 1fr) minmax(320px, 1fr)')
expect(image.getAttribute('src')).toBe('/api/media/file/press-contact.jpg')
expect(
screen.getByTestId('press-contact-copy').compareDocumentPosition(imageColumn) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
it('clears only the visual and keeps the contact copy in a compact fallback-free layout', () => {
const { rerender } = render(
<CmsRouteProvider
value={pressRoute({ url: '/api/media/file/press-contact.jpg' }, 'Press contact')}
>
<Press />
</CmsRouteProvider>,
)
expect(screen.getByTestId('press-contact-image')).toBeTruthy()
rerender(
<CmsRouteProvider value={pressRoute(undefined, '')}>
<Press />
</CmsRouteProvider>,
)
const layout = screen.getByTestId('press-contact-layout')
expect(screen.queryByTestId('press-contact-image')).toBeNull()
expect(layout.getAttribute('data-layout')).toBe('copy-only')
expect(layout.style.gridTemplateColumns).toBe('1fr')
expect(
within(screen.getByTestId('press-contact-copy')).getByText(
pressIndexContent.downloads.contactName,
),
).toBeTruthy()
expect(within(layout).queryByRole('img')).toBeNull()
})
it('stacks copy before the selected image on mobile', () => {
viewport.mobile = true
render(
<CmsRouteProvider
value={pressRoute({ filename: 'press-contact-mobile.jpg' }, 'Mobile press contact')}
>
<Press />
</CmsRouteProvider>,
)
const layout = screen.getByTestId('press-contact-layout')
const copy = screen.getByTestId('press-contact-copy')
const imageColumn = screen.getByTestId('press-contact-image')
expect(layout.getAttribute('data-layout')).toBe('stacked')
expect(layout.style.gridTemplateColumns).toBe('1fr')
expect(
within(imageColumn).getByRole('img', { name: 'Mobile press contact' }).getAttribute('src'),
).toBe('/api/media/file/press-contact-mobile.jpg')
expect(
copy.compareDocumentPosition(imageColumn) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
})

View File

@@ -0,0 +1,464 @@
import config from '@/payload.config'
import { resolveHomeWinners } from '@/spa/homeWinnerSelection'
import { selectJuryDocs } from '@/spa/juryMembers'
import type { CmsRouteDoc } from '@/spa/cmsRoute'
import { getPublicationQueryOptions } from '@/utilities/publicationQuery'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
const lexicalDocument = {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal' as const,
style: '',
text: 'Route hydration fixture',
type: 'text',
version: 1,
},
],
direction: 'ltr' as const,
format: '' as const,
indent: 0,
textFormat: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr' as const,
format: '' as const,
indent: 0,
type: 'root',
version: 1,
},
}
const fixtureKey = `route-hydration-${process.pid}-${Date.now()}`
let payload: Payload
let mediaID: number
const cleanup: Array<() => Promise<unknown>> = []
async function findFixtureDocs(
collection: 'events' | 'jury-mitglieder' | 'partners' | 'posts' | 'preistraeger',
ids: Array<number | string>,
preview: boolean,
sort?: string,
): Promise<CmsRouteDoc[]> {
const result = await payload.find({
collection,
...getPublicationQueryOptions(preview),
depth: 1,
limit: 20,
pagination: false,
...(sort ? { sort } : {}),
where: { id: { in: ids } },
})
return result.docs as unknown as CmsRouteDoc[]
}
describe('public route hydration', () => {
beforeAll(async () => {
payload = await getPayload({ config })
const imageData = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
)
const media = await payload.create({
collection: 'media',
data: { alt: 'Route hydration fixture' },
file: {
data: imageData,
mimetype: 'image/png',
name: `${fixtureKey}.png`,
size: imageData.length,
},
})
mediaID = media.id
})
afterAll(async () => {
for (const remove of cleanup.reverse()) await remove()
if (mediaID) await payload.delete({ collection: 'media', id: mediaID })
})
it('omits an unpublished jury member selected on /netzwerk and restores it in preview', async () => {
const first = await payload.create({
collection: 'jury-mitglieder',
context: { disableRevalidate: true },
data: { _status: 'published', name: `${fixtureKey} jury first`, sortOrder: 10 },
draft: false,
})
const unpublished = await payload.create({
collection: 'jury-mitglieder',
context: { disableRevalidate: true },
data: { _status: 'draft', name: `${fixtureKey} jury unpublished`, sortOrder: 20 },
draft: false,
})
const third = await payload.create({
collection: 'jury-mitglieder',
context: { disableRevalidate: true },
data: { _status: 'published', name: `${fixtureKey} jury third`, sortOrder: 30 },
draft: false,
})
cleanup.push(
() =>
payload.delete({
collection: 'jury-mitglieder',
context: { disableRevalidate: true },
id: first.id,
}),
() =>
payload.delete({
collection: 'jury-mitglieder',
context: { disableRevalidate: true },
id: unpublished.id,
}),
() =>
payload.delete({
collection: 'jury-mitglieder',
context: { disableRevalidate: true },
id: third.id,
}),
)
const ids = [first.id, unpublished.id, third.id]
const selection = [third, unpublished, first]
const publicDocs = await findFixtureDocs('jury-mitglieder', ids, false, 'sortOrder')
const previewDocs = await findFixtureDocs('jury-mitglieder', ids, true, 'sortOrder')
expect(selectJuryDocs(selection, publicDocs).map((doc) => doc.id)).toEqual([third.id, first.id])
expect(selectJuryDocs(selection, previewDocs).map((doc) => doc.id)).toEqual([
third.id,
unpublished.id,
first.id,
])
})
it('omits an unpublished selected homepage winner and restores it after republishing without a page edit', async () => {
const first = await payload.create({
collection: 'preistraeger',
context: { disableRevalidate: true },
data: {
_status: 'published',
slug: `${fixtureKey}-winner-first`,
title: `${fixtureKey} winner first`,
year: 2026,
},
draft: false,
})
const unpublished = await payload.create({
collection: 'preistraeger',
context: { disableRevalidate: true },
data: {
_status: 'draft',
slug: `${fixtureKey}-winner-unpublished`,
title: `${fixtureKey} winner unpublished`,
year: 2026,
},
draft: false,
})
const third = await payload.create({
collection: 'preistraeger',
context: { disableRevalidate: true },
data: {
_status: 'published',
slug: `${fixtureKey}-winner-third`,
title: `${fixtureKey} winner third`,
year: 2026,
},
draft: false,
})
cleanup.push(
() =>
payload.delete({
collection: 'preistraeger',
context: { disableRevalidate: true },
id: first.id,
}),
() =>
payload.delete({
collection: 'preistraeger',
context: { disableRevalidate: true },
id: unpublished.id,
}),
() =>
payload.delete({
collection: 'preistraeger',
context: { disableRevalidate: true },
id: third.id,
}),
)
const ids = [first.id, unpublished.id, third.id]
const selection = [third, unpublished, first]
const publicDocs = await findFixtureDocs('preistraeger', ids, false)
const previewDocs = await findFixtureDocs('preistraeger', ids, true)
expect(resolveHomeWinners(selection, publicDocs).map((doc) => doc.id)).toEqual([
third.id,
first.id,
])
expect(resolveHomeWinners(selection, previewDocs).map((doc) => doc.id)).toEqual([
third.id,
unpublished.id,
first.id,
])
await payload.update({
collection: 'preistraeger',
context: { disableRevalidate: true },
id: unpublished.id,
data: { _status: 'published' },
draft: false,
})
const republishedDocs = await findFixtureDocs('preistraeger', ids, false)
expect(resolveHomeWinners(selection, republishedDocs).map((doc) => doc.id)).toEqual([
third.id,
unpublished.id,
first.id,
])
})
it('omits an unpublished partner from the homepage ticker and preserves sort order', async () => {
const first = await payload.create({
collection: 'partners',
context: { disableRevalidate: true },
data: {
_status: 'published',
logo: mediaID,
name: `${fixtureKey} partner first`,
sortOrder: 10,
stableId: `${fixtureKey}-partner-first`,
tier: 1,
},
draft: false,
})
const unpublished = await payload.create({
collection: 'partners',
context: { disableRevalidate: true },
data: {
_status: 'draft',
logo: mediaID,
name: `${fixtureKey} partner unpublished`,
sortOrder: 20,
stableId: `${fixtureKey}-partner-unpublished`,
tier: 2,
},
draft: false,
})
const third = await payload.create({
collection: 'partners',
context: { disableRevalidate: true },
data: {
_status: 'published',
logo: mediaID,
name: `${fixtureKey} partner third`,
sortOrder: 30,
stableId: `${fixtureKey}-partner-third`,
tier: 3,
},
draft: false,
})
cleanup.push(
() =>
payload.delete({
collection: 'partners',
context: { disableRevalidate: true },
id: first.id,
}),
() =>
payload.delete({
collection: 'partners',
context: { disableRevalidate: true },
id: unpublished.id,
}),
() =>
payload.delete({
collection: 'partners',
context: { disableRevalidate: true },
id: third.id,
}),
)
const ids = [first.id, unpublished.id, third.id]
const publicDocs = await findFixtureDocs('partners', ids, false, 'sortOrder')
const previewDocs = await findFixtureDocs('partners', ids, true, 'sortOrder')
expect(publicDocs.map((doc) => doc.id)).toEqual([first.id, third.id])
expect(previewDocs.map((doc) => doc.id)).toEqual([first.id, unpublished.id, third.id])
})
it('leaves no event row after the final /presse event is unpublished', async () => {
const event = await payload.create({
collection: 'events',
context: { disableRevalidate: true },
data: {
_status: 'published',
slug: `${fixtureKey}-event-final`,
title: `${fixtureKey} event final`,
},
draft: false,
})
cleanup.push(() =>
payload.delete({
collection: 'events',
context: { disableRevalidate: true },
id: event.id,
}),
)
expect((await findFixtureDocs('events', [event.id], false)).map((doc) => doc.id)).toEqual([
event.id,
])
await payload.update({
collection: 'events',
context: { disableRevalidate: true },
id: event.id,
data: { _status: 'draft' },
draft: false,
})
expect(await findFixtureDocs('events', [event.id], false)).toEqual([])
expect((await findFixtureDocs('events', [event.id], true)).map((doc) => doc.id)).toEqual([
event.id,
])
})
it('omits an unpublished event previously rendered on /presse', async () => {
const published = await payload.create({
collection: 'events',
context: { disableRevalidate: true },
data: {
_status: 'published',
slug: `${fixtureKey}-event-published`,
title: `${fixtureKey} event published`,
},
draft: false,
})
const unpublished = await payload.create({
collection: 'events',
context: { disableRevalidate: true },
data: {
_status: 'draft',
slug: `${fixtureKey}-event-unpublished`,
title: `${fixtureKey} event unpublished`,
},
draft: false,
})
cleanup.push(
() =>
payload.delete({
collection: 'events',
context: { disableRevalidate: true },
id: published.id,
}),
() =>
payload.delete({
collection: 'events',
context: { disableRevalidate: true },
id: unpublished.id,
}),
)
const ids = [published.id, unpublished.id]
const publicDocs = await findFixtureDocs('events', ids, false)
const previewDocs = await findFixtureDocs('events', ids, true)
expect(publicDocs.map((doc) => doc.id)).toEqual([published.id])
expect(previewDocs.map((doc) => doc.id).sort()).toEqual(ids.sort())
})
it('leaves no news card after the final /presse article is unpublished', async () => {
const post = await payload.create({
collection: 'posts',
context: { disableRevalidate: true },
data: {
_status: 'published',
content: lexicalDocument,
slug: `${fixtureKey}-post-final`,
title: `${fixtureKey} post final`,
},
draft: false,
})
cleanup.push(() =>
payload.delete({
collection: 'posts',
context: { disableRevalidate: true },
id: post.id,
}),
)
expect((await findFixtureDocs('posts', [post.id], false)).map((doc) => doc.id)).toEqual([
post.id,
])
await payload.update({
collection: 'posts',
context: { disableRevalidate: true },
id: post.id,
data: { _status: 'draft' },
draft: false,
})
expect(await findFixtureDocs('posts', [post.id], false)).toEqual([])
expect((await findFixtureDocs('posts', [post.id], true)).map((doc) => doc.id)).toEqual([
post.id,
])
})
it('omits an unpublished press article previously rendered on /presse', async () => {
const published = await payload.create({
collection: 'posts',
context: { disableRevalidate: true },
data: {
_status: 'published',
content: lexicalDocument,
slug: `${fixtureKey}-post-published`,
title: `${fixtureKey} post published`,
},
draft: false,
})
const unpublished = await payload.create({
collection: 'posts',
context: { disableRevalidate: true },
data: {
_status: 'draft',
content: lexicalDocument,
slug: `${fixtureKey}-post-unpublished`,
title: `${fixtureKey} post unpublished`,
},
draft: false,
})
cleanup.push(
() =>
payload.delete({
collection: 'posts',
context: { disableRevalidate: true },
id: published.id,
}),
() =>
payload.delete({
collection: 'posts',
context: { disableRevalidate: true },
id: unpublished.id,
}),
)
const ids = [published.id, unpublished.id]
const publicDocs = await findFixtureDocs('posts', ids, false)
const previewDocs = await findFixtureDocs('posts', ids, true)
expect(publicDocs.map((doc) => doc.id)).toEqual([published.id])
expect(previewDocs.map((doc) => doc.id).sort()).toEqual(ids.sort())
})
})

View File

@@ -0,0 +1,260 @@
import config from '@/payload.config'
import { getPublicationQueryOptions } from '@/utilities/publicationQuery'
import type { Payload, RequiredDataFromCollectionSlug } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
const publicationCollections = [
'pages',
'preistraeger',
'jury-mitglieder',
'partners',
'events',
'posts',
] as const
type PublicationCollection = (typeof publicationCollections)[number]
type PublicationCase<TCollection extends PublicationCollection = PublicationCollection> = {
collection: TCollection
data: (label: string) => RequiredDataFromCollectionSlug<TCollection>
hasPublicDetailRoute: boolean
}
const lexicalDocument = {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal' as const,
style: '',
text: 'Publication contract fixture',
type: 'text',
version: 1,
},
],
direction: 'ltr' as const,
format: '' as const,
indent: 0,
textFormat: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr' as const,
format: '' as const,
indent: 0,
type: 'root',
version: 1,
},
}
let mediaID: number
const cleanup: Array<() => Promise<unknown>> = []
const publicationCases: PublicationCase[] = [
{
collection: 'pages',
data: (label) => ({
layout: [{ blockType: 'content', columns: [] }],
_status: 'published',
slug: `publication-${label}`,
spaPath: `/publication-${label}`,
title: `Publication ${label}`,
}),
hasPublicDetailRoute: true,
},
{
collection: 'preistraeger',
data: (label) => ({
_status: 'published',
slug: `publication-${label}`,
spaPath: `/preistraeger/publication-${label}`,
title: `Publication ${label}`,
}),
hasPublicDetailRoute: true,
},
{
collection: 'jury-mitglieder',
data: (label) => ({ _status: 'published', name: `Publication ${label}` }),
hasPublicDetailRoute: false,
},
{
collection: 'partners',
data: (label) => ({
_status: 'published',
logo: mediaID,
name: `Publication ${label}`,
stableId: `publication-${label}`,
tier: 3,
}),
hasPublicDetailRoute: false,
},
{
collection: 'events',
data: (label) => ({
_status: 'published',
slug: `publication-${label}`,
spaPath: `/presse/events/publication-${label}`,
title: `Publication ${label}`,
}),
hasPublicDetailRoute: true,
},
{
collection: 'posts',
data: (label) => ({
_status: 'published',
content: lexicalDocument,
slug: `publication-${label}`,
spaPath: `/presse/blog/publication-${label}`,
title: `Publication ${label}`,
}),
hasPublicDetailRoute: true,
},
]
let payload: Payload
describe('draft-enabled collection publication contract', () => {
beforeAll(async () => {
payload = await getPayload({ config })
const imageData = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
)
const media = await payload.create({
collection: 'media',
data: { alt: 'Publication contract fixture' },
file: {
data: imageData,
mimetype: 'image/png',
name: `publication-contract-${process.pid}.png`,
size: imageData.length,
},
})
mediaID = media.id
})
afterAll(async () => {
for (const removeFixture of cleanup.reverse()) {
await removeFixture()
}
if (mediaID) {
await payload.delete({ collection: 'media', id: mediaID })
}
})
it('keeps anonymous public and authenticated preview query modes explicit', () => {
expect(getPublicationQueryOptions(false)).toEqual({
draft: false,
overrideAccess: false,
})
expect(getPublicationQueryOptions(true)).toEqual({
draft: true,
overrideAccess: true,
})
})
it.each(publicationCases)(
'returns only published $collection documents to anonymous public reads',
async ({ collection, data, hasPublicDetailRoute }) => {
const published = await payload.create({
collection,
context: { disableRevalidate: true },
data: data(`${collection}-published`),
draft: false,
})
const unpublished = await payload.create({
collection,
context: { disableRevalidate: true },
data: data(`${collection}-unpublished`),
draft: false,
})
cleanup.push(
() =>
payload.delete({
collection,
context: { disableRevalidate: true },
id: published.id,
}),
() =>
payload.delete({
collection,
context: { disableRevalidate: true },
id: unpublished.id,
}),
)
const storedDraft = await payload.update({
collection,
context: { disableRevalidate: true },
id: unpublished.id,
data: { _status: 'draft' },
draft: false,
})
expect(published._status).toBe('published')
expect(storedDraft._status).toBe('draft')
const publicResult = await payload.find({
collection,
...getPublicationQueryOptions(false),
pagination: false,
where: {
id: {
in: [published.id, unpublished.id],
},
},
})
expect(publicResult.docs.map((doc) => doc.id)).toEqual([published.id])
expect(publicResult.docs.every((doc) => doc._status === 'published')).toBe(true)
const previewResult = await payload.find({
collection,
...getPublicationQueryOptions(true),
pagination: false,
where: {
id: {
in: [published.id, unpublished.id],
},
},
})
expect(previewResult.docs.map((doc) => doc.id).sort()).toEqual(
[published.id, unpublished.id].sort(),
)
if (hasPublicDetailRoute) {
await expect(
payload.findByID({
collection,
id: published.id,
...getPublicationQueryOptions(false),
}),
).resolves.toMatchObject({ id: published.id, _status: 'published' })
await expect(
payload.findByID({
collection,
id: unpublished.id,
...getPublicationQueryOptions(false),
}),
).rejects.toThrow()
await expect(
payload.findByID({
collection,
id: unpublished.id,
...getPublicationQueryOptions(true),
}),
).resolves.toMatchObject({ id: unpublished.id, _status: 'draft' })
}
},
)
})

View File

@@ -0,0 +1,210 @@
import { createRequire } from 'node:module'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getSitemapBaseURL, normalizeSitemapBaseURL } from '@/utilities/sitemapBaseURL.cjs'
type SitemapCollection = 'pages' | 'posts' | 'events' | 'preistraeger'
type SitemapDocument = {
_status: 'draft' | 'published'
slug?: string
spaPath?: string
updatedAt?: string
}
type SitemapFindQuery = {
collection: SitemapCollection
draft?: boolean
overrideAccess?: boolean
where?: {
_status?: {
equals?: string
}
}
}
const routeState = vi.hoisted(() => ({
docs: {
events: [],
pages: [],
posts: [],
preistraeger: [],
} as Record<SitemapCollection, SitemapDocument[]>,
queries: [] as SitemapFindQuery[],
}))
vi.mock('@payload-config', () => ({ default: {} }))
vi.mock('next/cache', () => ({
unstable_cache: (callback: () => unknown) => callback,
}))
vi.mock('payload', () => ({
getPayload: async () => ({
find: async (query: SitemapFindQuery) => {
routeState.queries.push(query)
const isPublishedPublicQuery =
query.draft === false &&
query.overrideAccess === false &&
query.where?._status?.equals === 'published'
return {
docs: isPublishedPublicQuery
? routeState.docs[query.collection].filter((document) => document._status === 'published')
: routeState.docs[query.collection],
}
},
}),
}))
import { GET as getPagesSitemap } from '@/app/(frontend)/(sitemaps)/pages-sitemap.xml/route'
import { GET as getPostsSitemap } from '@/app/(frontend)/(sitemaps)/posts-sitemap.xml/route'
const productionURL = 'https://www.bayerischer-mittelstandspreis.de'
async function sitemapLocations(response: Response): Promise<string[]> {
const xml = await response.text()
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1])
}
describe('sitemap route generation', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_SERVER_URL = `${productionURL}/`
process.env.VERCEL_PROJECT_PRODUCTION_URL = ''
for (const collection of Object.keys(routeState.docs) as SitemapCollection[]) {
routeState.docs[collection] = []
}
routeState.queries = []
})
afterEach(() => {
vi.restoreAllMocks()
})
it.each([
{
label: 'stored homepage path',
document: { _status: 'published', slug: 'startseite', spaPath: '/' },
expectedPath: '/',
},
{
label: 'home slug fallback',
document: { _status: 'published', slug: 'home' },
expectedPath: '/',
},
{
label: 'startseite slug fallback',
document: { _status: 'published', slug: 'startseite' },
expectedPath: '/',
},
{
label: 'normal stored page path',
document: { _status: 'published', slug: 'legacy-contact', spaPath: '/kontakt' },
expectedPath: '/kontakt',
},
] as const)('emits the $label', async ({ document, expectedPath }) => {
routeState.docs.pages = [document]
await expect(sitemapLocations(await getPagesSitemap())).resolves.toEqual([
`${productionURL}${expectedPath}`,
])
})
it.each([
{ collection: 'posts', prefix: '/presse/blog', slug: 'presseartikel' },
{ collection: 'events', prefix: '/presse/events', slug: 'preisverleihung' },
{ collection: 'preistraeger', prefix: '/preistraeger', slug: 'muster-gmbh' },
] as const)(
'emits $collection detail routes under $prefix',
async ({ collection, prefix, slug }) => {
routeState.docs[collection] = [
{
_status: 'published',
slug,
spaPath: `${prefix}/${slug}`,
},
]
await expect(sitemapLocations(await getPostsSitemap())).resolves.toContain(
`${productionURL}${prefix}/${slug}`,
)
},
)
it('excludes unpublished documents through the established anonymous public query contract', async () => {
routeState.docs.pages = [
{ _status: 'published', slug: 'sichtbar', spaPath: '/sichtbar' },
{ _status: 'draft', slug: 'entwurf', spaPath: '/entwurf' },
]
routeState.docs.posts = [
{ _status: 'published', slug: 'sichtbar', spaPath: '/presse/blog/sichtbar' },
{ _status: 'draft', slug: 'entwurf', spaPath: '/presse/blog/entwurf' },
]
const locations = [
...(await sitemapLocations(await getPagesSitemap())),
...(await sitemapLocations(await getPostsSitemap())),
]
expect(locations).toContain(`${productionURL}/sichtbar`)
expect(locations).toContain(`${productionURL}/presse/blog/sichtbar`)
expect(locations.some((location) => location.includes('entwurf'))).toBe(false)
expect(routeState.queries).toHaveLength(4)
expect(routeState.queries).toEqual(
expect.arrayContaining([
expect.objectContaining({
draft: false,
overrideAccess: false,
where: { _status: { equals: 'published' } },
}),
]),
)
})
})
describe('sitemap production base URL', () => {
it.each([
[
'trailing path and slash',
'https://www.bayerischer-mittelstandspreis.de/path///',
productionURL,
],
['bare deployment hostname', 'bmp-production.vercel.app', 'https://bmp-production.vercel.app'],
])('normalizes a %s', (_label, configuredURL, expectedURL) => {
expect(normalizeSitemapBaseURL(configuredURL)).toBe(expectedURL)
})
it('rejects a localhost production origin', () => {
expect(() => normalizeSitemapBaseURL('http://localhost:3000', { production: true })).toThrow(
'Production sitemap base URL must be a public HTTPS origin.',
)
})
it('fails instead of emitting a fallback when no public URL is configured', () => {
expect(() => getSitemapBaseURL({ NODE_ENV: 'production' })).toThrow(
'Sitemap base URL is not configured.',
)
})
it('uses the normalized production origin for the robots sitemap URLs', () => {
const require = createRequire(import.meta.url)
const configPath = require.resolve('../../next-sitemap.config.cjs')
delete require.cache[configPath]
process.env.NEXT_PUBLIC_SERVER_URL = `${productionURL}/deployment/path/`
const config = require(configPath) as {
robotsTxtOptions: { additionalSitemaps: string[] }
siteUrl: string
}
expect(config.siteUrl).toBe(productionURL)
expect(config.robotsTxtOptions.additionalSitemaps).toEqual([
`${productionURL}/pages-sitemap.xml`,
`${productionURL}/posts-sitemap.xml`,
])
})
})

View File

@@ -7,6 +7,6 @@ export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['tests/int/**/*.int.spec.ts'],
include: ['tests/int/**/*.int.spec.{ts,tsx}'],
},
})